Skip to content
Open
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
36 changes: 34 additions & 2 deletions src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, Type>
Expand Down Expand Up @@ -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();
Expand All @@ -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;
}

Expand All @@ -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(
Expand Down
51 changes: 51 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-15056.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php // lint >= 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<string> */
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);
}
}
17 changes: 17 additions & 0 deletions tests/PHPStan/Rules/Variables/EmptyRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
],
]);
}

}
17 changes: 17 additions & 0 deletions tests/PHPStan/Rules/Variables/IssetRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
28 changes: 28 additions & 0 deletions tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'], [
Expand Down
30 changes: 30 additions & 0 deletions tests/PHPStan/Rules/Variables/data/bug-15056.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php declare(strict_types = 1);

namespace Bug15056;

class Service
{
private string $priceInfo;

public function __construct(?string $priceInfo = null)
{
$this->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
}
}
Loading
Loading