diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 0f33ced2e3..59626e60ab 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -102,6 +102,7 @@ use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; use PHPStan\Type\VoidType; +use Serializable; use Throwable; use function abs; use function array_filter; @@ -141,6 +142,9 @@ class MutatingScope implements Scope, NodeCallbackInvoker, CollectedDataEmitter public const KEEP_VOID_ATTRIBUTE_NAME = 'keepVoid'; private const COMPLEX_UNION_TYPE_MEMBER_LIMIT = 8; + /** Magic methods that let the author decide which properties survive a serialize()/unserialize() round trip. */ + private const CUSTOM_SERIALIZATION_METHODS = ['__sleep', '__serialize', '__unserialize']; + /** * @internal accessed by ScopeOps (native and PHP implementations) * @var array @@ -306,6 +310,7 @@ public function enterDeclareStrictTypes(): self */ private function rememberConstructorExpressions(array $currentExpressionTypes): array { + $rememberPropertyState = !$this->classHasCustomSerialization(); $expressionTypes = []; foreach ($currentExpressionTypes as $exprString => $expressionTypeHolder) { $expr = $expressionTypeHolder->getExpr(); @@ -318,10 +323,14 @@ private function rememberConstructorExpressions(array $currentExpressionTypes): continue; } } elseif ($expr instanceof PropertyFetch) { - if (!$this->isReadonlyPropertyFetch($expr, true)) { + if (!$rememberPropertyState || !$this->isReadonlyPropertyFetch($expr, true)) { + continue; + } + } elseif ($expr instanceof PropertyInitializationExpr) { + if (!$rememberPropertyState) { continue; } - } elseif (!$expr instanceof ConstFetch && !$expr instanceof PropertyInitializationExpr) { + } elseif (!$expr instanceof ConstFetch) { continue; } @@ -335,6 +344,29 @@ private function rememberConstructorExpressions(array $currentExpressionTypes): return $expressionTypes; } + /** + * A class with custom serialization logic can be rebuilt by unserialize() + * without the constructor ever running, and the author decides which properties + * make the round trip - so nothing the constructor established can be relied upon + * in the other methods. + */ + private function classHasCustomSerialization(): bool + { + if (!$this->isInClass()) { + return false; + } + + $classReflection = $this->getClassReflection(); + foreach (self::CUSTOM_SERIALIZATION_METHODS as $methodName) { + if ($classReflection->hasNativeMethod($methodName)) { + return true; + } + } + + return $classReflection->implementsInterface(Serializable::class) + && $classReflection->hasNativeMethod('unserialize'); + } + public function rememberConstructorScope(): self { return $this->scopeFactory->create( diff --git a/tests/PHPStan/Analyser/nsrt/bug-15056.php b/tests/PHPStan/Analyser/nsrt/bug-15056.php new file mode 100644 index 0000000000..ff3decb5bc --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15056.php @@ -0,0 +1,51 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug15056; + +use function PHPStan\Testing\assertType; + +class ServiceWithSleep +{ + private readonly string $readonlyString; + private string $string; + + public function __construct() + { + $this->readonlyString = 'foo'; + $this->string = 'bar'; + } + + public function doFoo(): void + { + assertType('string', $this->readonlyString); + assertType('string|null', $this->readonlyString ?? null); + assertType('string|null', $this->string ?? null); + } + + /** @return list */ + public function __sleep(): array + { + return []; + } +} + +class ServiceWithoutSleep +{ + private readonly string $readonlyString; + private string $string; + + public function __construct() + { + $this->readonlyString = 'foo'; + $this->string = 'bar'; + } + + public function doFoo(): void + { + assertType("'foo'", $this->readonlyString); + assertType("'foo'", $this->readonlyString ?? null); + assertType('string|null', $this->string ?? null); + } +} diff --git a/tests/PHPStan/Rules/Variables/EmptyRuleTest.php b/tests/PHPStan/Rules/Variables/EmptyRuleTest.php index 6ab498be10..1f7ad2be15 100644 --- a/tests/PHPStan/Rules/Variables/EmptyRuleTest.php +++ b/tests/PHPStan/Rules/Variables/EmptyRuleTest.php @@ -257,4 +257,21 @@ public function testNullCoalesceAssignRightSideScope(): void $this->analyse([__DIR__ . '/data/null-coalesce-assign-right-side-scope.php'], []); } + #[RequiresPhp('>= 8.2')] + public function testPropertyInitializationCustomSerialization(): void + { + $this->treatPhpDocTypesAsCertain = true; + + $this->analyse([__DIR__ . '/data/property-initialization-custom-serialization.php'], [ + [ + 'Property PropertyInitializationCustomSerialization\NoSerialization::$true in empty() is not falsy nor uninitialized.', + 23, + ], + [ + 'Property PropertyInitializationCustomSerialization\OnlyWakeup::$true in empty() is not falsy nor uninitialized.', + 44, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Variables/IssetRuleTest.php b/tests/PHPStan/Rules/Variables/IssetRuleTest.php index db77f2aef5..e660eb2bcf 100644 --- a/tests/PHPStan/Rules/Variables/IssetRuleTest.php +++ b/tests/PHPStan/Rules/Variables/IssetRuleTest.php @@ -518,6 +518,23 @@ public function testIssetAfterRememberedConstructor(): void ]); } + #[RequiresPhp('>= 8.2')] + public function testPropertyInitializationCustomSerialization(): void + { + $this->treatPhpDocTypesAsCertain = true; + + $this->analyse([__DIR__ . '/data/property-initialization-custom-serialization.php'], [ + [ + 'Property PropertyInitializationCustomSerialization\NoSerialization::$string in isset() is not nullable nor uninitialized.', + 21, + ], + [ + 'Property PropertyInitializationCustomSerialization\OnlyWakeup::$string in isset() is not nullable nor uninitialized.', + 42, + ], + ]); + } + public function testPr4374(): void { $this->treatPhpDocTypesAsCertain = true; diff --git a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php index 1635fe7170..79e82d25c4 100644 --- a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php +++ b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php @@ -597,6 +597,34 @@ public function testNullCoalesceAssignRightSideScope(): void ]); } + public function testBug15056(): void + { + $this->analyse([__DIR__ . '/data/bug-15056.php'], []); + } + + #[RequiresPhp('>= 8.2')] + public function testPropertyInitializationCustomSerialization(): void + { + $this->analyse([__DIR__ . '/data/property-initialization-custom-serialization.php'], [ + [ + 'Property PropertyInitializationCustomSerialization\NoSerialization::$string on left side of ?? is not nullable nor uninitialized.', + 20, + ], + [ + 'Property PropertyInitializationCustomSerialization\OnlyWakeup::$string on left side of ?? is not nullable nor uninitialized.', + 41, + ], + [ + 'Property PropertyInitializationCustomSerialization\PromotedNoSerialization::$string on left side of ?? is not nullable nor uninitialized.', + 238, + ], + [ + 'Property PropertyInitializationCustomSerialization\CoalesceAssignNoSerialization::$string on left side of ??= is not nullable nor uninitialized.', + 271, + ], + ]); + } + public function testBug15046(): void { $this->analyse([__DIR__ . '/data/bug-15046.php'], [ diff --git a/tests/PHPStan/Rules/Variables/data/bug-15056.php b/tests/PHPStan/Rules/Variables/data/bug-15056.php new file mode 100644 index 0000000000..cbbb8d9f0e --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-15056.php @@ -0,0 +1,30 @@ +initDependencies($priceInfo); + } + + public function getPriceInfo(): string + { + $this->initDependencies($this->priceInfo ?? null); + + return $this->priceInfo; + } + + protected function initDependencies(?string $priceInfo): void + { + $this->priceInfo = $priceInfo ?? 'foobar'; // in reality: fetch from contiainer + } + + public function __sleep(): array + { + return []; // priceInfo will be lost on serialization + } +} diff --git a/tests/PHPStan/Rules/Variables/data/property-initialization-custom-serialization.php b/tests/PHPStan/Rules/Variables/data/property-initialization-custom-serialization.php new file mode 100644 index 0000000000..499f913c17 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/property-initialization-custom-serialization.php @@ -0,0 +1,317 @@ += 8.2 + +namespace PropertyInitializationCustomSerialization; + +use Serializable; + +class NoSerialization +{ + private string $string; + private true $true; + + public function __construct() + { + $this->string = 'foo'; + $this->true = true; + } + + public function doFoo(): void + { + echo $this->string ?? 'default'; + if (isset($this->string)) { + } + if (empty($this->true)) { + } + } +} + +class OnlyWakeup +{ + private string $string; + private true $true; + + public function __construct() + { + $this->string = 'foo'; + $this->true = true; + } + + public function doFoo(): void + { + echo $this->string ?? 'default'; + if (isset($this->string)) { + } + if (empty($this->true)) { + } + } + + public function __wakeup(): void + { + } +} + +class Sleep +{ + private string $string; + private true $true; + + public function __construct() + { + $this->string = 'foo'; + $this->true = true; + } + + public function doFoo(): void + { + echo $this->string ?? 'default'; + if (isset($this->string)) { + } + if (empty($this->true)) { + } + } + + /** @return list */ + public function __sleep(): array + { + return []; + } +} + +class SerializeAndUnserialize +{ + private string $string; + private true $true; + + public function __construct() + { + $this->string = 'foo'; + $this->true = true; + } + + public function doFoo(): void + { + echo $this->string ?? 'default'; + if (isset($this->string)) { + } + if (empty($this->true)) { + } + } + + /** @return array */ + public function __serialize(): array + { + return []; + } + + /** @param array $data */ + public function __unserialize(array $data): void + { + } +} + +class OnlyUnserialize +{ + private string $string; + private true $true; + + public function __construct() + { + $this->string = 'foo'; + $this->true = true; + } + + public function doFoo(): void + { + echo $this->string ?? 'default'; + if (isset($this->string)) { + } + if (empty($this->true)) { + } + } + + /** @param array $data */ + public function __unserialize(array $data): void + { + } +} + +class ParentWithSleep +{ + /** @return list */ + public function __sleep(): array + { + return []; + } +} + +class InheritsSleep extends ParentWithSleep +{ + private string $string; + private true $true; + + public function __construct() + { + $this->string = 'foo'; + $this->true = true; + } + + public function doFoo(): void + { + echo $this->string ?? 'default'; + if (isset($this->string)) { + } + if (empty($this->true)) { + } + } +} + +trait SleepTrait +{ + /** @return list */ + public function __sleep(): array + { + return []; + } +} + +class SleepFromTrait +{ + use SleepTrait; + + private string $string; + private true $true; + + public function __construct() + { + $this->string = 'foo'; + $this->true = true; + } + + public function doFoo(): void + { + echo $this->string ?? 'default'; + if (isset($this->string)) { + } + if (empty($this->true)) { + } + } +} + +class OldSchoolSerializable implements Serializable +{ + private string $string; + private true $true; + + public function __construct() + { + $this->string = 'foo'; + $this->true = true; + } + + public function doFoo(): void + { + echo $this->string ?? 'default'; + if (isset($this->string)) { + } + if (empty($this->true)) { + } + } + + public function serialize(): string + { + return ''; + } + + public function unserialize(string $data): void + { + } +} + +class PromotedNoSerialization +{ + public function __construct(private string $string) + { + } + + public function doFoo(): void + { + echo $this->string ?? 'default'; + } +} + +class PromotedSleep +{ + public function __construct(private string $string) + { + } + + public function doFoo(): void + { + echo $this->string ?? 'default'; + } + + /** @return list */ + public function __sleep(): array + { + return []; + } +} + +class CoalesceAssignNoSerialization +{ + private string $string; + + public function __construct() + { + $this->string = 'foo'; + } + + public function doFoo(): void + { + echo $this->string ??= 'default'; + } +} + +class CoalesceAssignSleep +{ + private string $string; + + public function __construct() + { + $this->string = 'foo'; + } + + public function doFoo(): void + { + echo $this->string ??= 'default'; + } + + /** @return list */ + public function __sleep(): array + { + return []; + } +} + +function anonymousClassWithSleep(): object +{ + return new class { + private string $string; + + public function __construct() + { + $this->string = 'foo'; + } + + public function doFoo(): void + { + echo $this->string ?? 'default'; + } + + /** @return list */ + public function __sleep(): array + { + return []; + } + }; +}