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
27 changes: 20 additions & 7 deletions src/Analyser/ExprHandler/AssignHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -760,7 +760,7 @@ private function doPrepareTarget(
// 1b. build the write chain (Set*OffsetValueTypeExpr nesting) after
// the root walk, so a property base's holder and current types are
// read from its stored result instead of pricing the unwalked fetch
$assignedPropertyExpr = $assignedExpr;
$assignedPropertyExpr = $this->getWrittenExpr($assignedExpr);
$chainVar = $originalVar;
while ($chainVar instanceof ArrayDimFetch) {
$varForSetOffsetValue = $chainVar->var;
Expand Down Expand Up @@ -1097,7 +1097,7 @@ private function doPrepareTarget(
if ($var instanceof ExistingArrayDimFetch) {
$originalVar = $var;
$dimFetchStack = [];
$assignedPropertyExpr = $assignedExpr;
$assignedPropertyExpr = $this->getWrittenExpr($assignedExpr);
while ($var instanceof ExistingArrayDimFetch) {
$varForSetOffsetValue = $var->getVar();
if ($varForSetOffsetValue instanceof PropertyFetch || $varForSetOffsetValue instanceof StaticPropertyFetch) {
Expand Down Expand Up @@ -1374,7 +1374,7 @@ public function applyWrite(
$conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $storage, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $storedAssignedExprResult);
}

$nodeScopeResolver->callNodeCallback($nodeCallback, new VariableAssignNode($var, $assignedExpr), $scopeBeforeAssignEval, $storage);
$nodeScopeResolver->callNodeCallback($nodeCallback, new VariableAssignNode($var, $this->getWrittenExpr($assignedExpr)), $scopeBeforeAssignEval, $storage);

$nativeType = $this->readAssignedValueType($nodeScopeResolver, $storedAssignedExprResult, $assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain());
$scope = $scope->assignVariable(
Expand Down Expand Up @@ -1596,7 +1596,7 @@ public function applyWrite(
if ($templateArgumentFrame !== null) {
$scope = $scope->addTemplateArgumentConstraints($this->templateArgumentObserver->collectSend($propertyReflection->getWritableType(), $assignedExprType));
}
$nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scopeBeforeAssignEval, $storage);
$nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $this->getWrittenExpr($assignedExpr), $isAssignOp), $scopeBeforeAssignEval, $storage);
if ($propertyReflection->canChangeTypeAfterAssignment()) {
if ($propertyReflection->hasNativeType()) {
$propertyNativeType = $propertyReflection->getNativeType();
Expand Down Expand Up @@ -1657,7 +1657,7 @@ public function applyWrite(
} else {
// fallback
$assignedExprType = $this->readAssignedValueType($nodeScopeResolver, $assignedValueResult, $assignedExpr, $scope);
$nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scopeBeforeAssignEval, $storage);
$nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $this->getWrittenExpr($assignedExpr), $isAssignOp), $scopeBeforeAssignEval, $storage);
$scope = $scope->assignExpression($var, $assignedExprType, $this->readAssignedValueType($nodeScopeResolver, $assignedValueResult, $assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain()));
// simulate dynamic property assign by __set to get throw points
if (!$propertyHolderType->hasMethod('__set')->no()) {
Expand Down Expand Up @@ -1693,7 +1693,7 @@ public function applyWrite(
$scope = $scope->addTemplateArgumentConstraints($this->templateArgumentObserver->collectSend($propertyReflection->getWritableType(), $assignedExprType));
}
}
$nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scopeBeforeAssignEval, $storage);
$nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $this->getWrittenExpr($assignedExpr), $isAssignOp), $scopeBeforeAssignEval, $storage);
if ($propertyReflection !== null && $propertyReflection->canChangeTypeAfterAssignment()) {
if ($propertyReflection->hasNativeType()) {
$propertyNativeType = $propertyReflection->getNativeType();
Expand Down Expand Up @@ -1724,7 +1724,7 @@ public function applyWrite(
} else {
// fallback
$assignedExprType = $this->readAssignedValueType($nodeScopeResolver, $assignedValueResult, $assignedExpr, $scope);
$nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scopeBeforeAssignEval, $storage);
$nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $this->getWrittenExpr($assignedExpr), $isAssignOp), $scopeBeforeAssignEval, $storage);
$scope = $scope->assignExpression($var, $assignedExprType, $this->readAssignedValueType($nodeScopeResolver, $assignedValueResult, $assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain()));
}
} elseif ($kind === PreparedAssignTarget::KIND_LIST) {
Expand Down Expand Up @@ -2051,6 +2051,19 @@ private function assignDynamicVariable(MutatingScope $scope, ExpressionResult $n
return $scope;
}

/**
* `$lvalue ??= value` writes only `value` - the type of the whole
* expression also covers the non-null old value it keeps in place.
*/
private function getWrittenExpr(Expr $assignedExpr): Expr
{
if ($assignedExpr instanceof Expr\AssignOp\Coalesce) {
return $assignedExpr->expr;
}
Comment on lines +2060 to +2062

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we have the same problem with other assignment operators?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the other assignment operators don't have this problem, so the code stays as it is. I added a test to show it and pushed it as cded8d547.

Why only ??= is affected: for $x OP= $v with any other operator, the value of the whole expression is exactly what gets written to $x. Passing that expression to PropertyAssignNode/VariableAssignNode is already correct. ??= is the only one where the two differ: the expression's type, nonNull($x) | $v, includes the old value, which is kept and never written.

Test: a new OtherAssignOpsDemo class in tests/PHPStan/Rules/Properties/data/bug-15228.php puts each compound assignment next to its expanded form:

  • $this->a .= 'x' and $this->a = $this->a . 'x' both report "(int|null) does not accept string".
  • $this->r += 5 and $this->r = $this->r + 5 both report "(int<0, 10>) does not accept int<5, 15>".

The errors match with checkUnionTypes on and off. With it off, the += pair isn't reported in either form, so they still agree. make tests and make phpstan both pass.

One unrelated thing came up while checking: $this->i /= $x on an int property, which gives int|float, isn't reported. The expanded $this->i = $this->i / $x isn't reported either, so it isn't specific to compound assignment and is outside this PR.


return $assignedExpr;
}

private function unwrapAssign(Expr $expr): Expr
{
if ($expr instanceof Assign) {
Expand Down
3 changes: 2 additions & 1 deletion src/Rules/Arrays/OffsetAccessValueAssignmentRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ public function processNode(Node $node, Scope $scope): array
return [];
}

if ($node instanceof Assign || $node instanceof Expr\AssignRef) {
if ($node instanceof Assign || $node instanceof Expr\AssignRef || $node instanceof AssignOp\Coalesce) {
// `$lvalue ??= value` writes only `value`
$assignedValueType = $scope->getType($node->expr);
} else {
$assignedValueType = $scope->getType($node);
Expand Down
14 changes: 14 additions & 0 deletions tests/PHPStan/Rules/Arrays/OffsetAccessValueAssignmentRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,18 @@ public function testBug10783(): void
$this->analyse([__DIR__ . '/../Variables/data/bug-10783.php'], []);
}

public function testCoalesceAssign(): void
{
$this->analyse([__DIR__ . '/data/offset-access-value-assignment-coalesce.php'], [
[
'OffsetAccessValueAssignmentCoalesce\\Foo does not accept string.',
40,
],
[
'OffsetAccessValueAssignmentCoalesce\\Foo does not accept string.',
53,
],
]);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php declare(strict_types = 1);

namespace OffsetAccessValueAssignmentCoalesce;

/**
* @implements \ArrayAccess<string, int>
*/
class Foo implements \ArrayAccess
{
public function offsetExists($offset): bool
{
return true;
}

public function offsetGet($offset): ?string
{
return 'x';
}

/**
* @param string|null $offset
* @param int $value
*/
public function offsetSet($offset, $value): void
{
}

public function offsetUnset($offset): void
{
}
}

function coalesceValid(Foo $foo): void
{
$foo['x'] ??= 5;
}

function coalesceInvalid(Foo $foo): void
{
$foo['x'] ??= 'boom';
}

function ifValid(Foo $foo): void
{
if (null === $foo['x']) {
$foo['x'] = 5;
}
}

function ifInvalid(Foo $foo): void
{
if (null === $foo['x']) {
$foo['x'] = 'boom';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ class TypesAssignedToPropertiesRuleTest extends RuleTestCase

private bool $checkNullables = true;

private bool $checkUnionTypes = true;

private bool $checkExplicitMixed = false;

private bool $checkImplicitMixed = false;
Expand All @@ -26,7 +28,7 @@ protected function getRule(): Rule
self::createReflectionProvider(),
checkNullables: $this->checkNullables,
checkThisOnly: false,
checkUnionTypes: true,
checkUnionTypes: $this->checkUnionTypes,
checkExplicitMixed: $this->checkExplicitMixed,
checkImplicitMixed: $this->checkImplicitMixed,
checkBenevolentUnionTypes: false,
Expand Down Expand Up @@ -1145,4 +1147,97 @@ public function testIntersectionWithTemplateMixed(): void
]);
}

public function testBug15228(): void
{
$this->analyse([__DIR__ . '/data/bug-15228.php'], [
[
'Property Bug15228\\CoalesceAssignDemo::$a (int|null) does not accept string.',
20,
],
[
'Property Bug15228\\CoalesceAssignDemo::$a (int|null) does not accept string.',
26,
],
[
'Property Bug15228\\CoalesceAssignDemo::$b (list<int>|null) does not accept list<string>.',
32,
],
[
'Property Bug15228\\CoalesceAssignDemo::$b (list<int>|null) does not accept list<string>.',
38,
],
[
'Static property Bug15228\\StaticCoalesceAssignDemo::$a (int|null) does not accept string.',
60,
],
[
'Static property Bug15228\\StaticCoalesceAssignDemo::$b (list<int>|null) does not accept list<string>.',
65,
],
[
'Property Bug15228\\OffsetCoalesceAssignDemo::$a (array<string, int>) does not accept array<string, int|string>.',
80,
],
[
'Property Bug15228\\OtherAssignOpsDemo::$a (int|null) does not accept string.',
98,
],
[
'Property Bug15228\\OtherAssignOpsDemo::$a (int|null) does not accept string.',
103,
],
[
'Property Bug15228\\OtherAssignOpsDemo::$r (int<0, 10>) does not accept int<5, 15>.',
108,
],
[
'Property Bug15228\\OtherAssignOpsDemo::$r (int<0, 10>) does not accept int<5, 15>.',
113,
],
]);
}

public function testBug15228WithoutCheckUnionTypes(): void
{
$this->checkUnionTypes = false;
$this->analyse([__DIR__ . '/data/bug-15228.php'], [
[
'Property Bug15228\\CoalesceAssignDemo::$a (int|null) does not accept string.',
20,
],
[
'Property Bug15228\\CoalesceAssignDemo::$a (int|null) does not accept string.',
26,
],
[
'Property Bug15228\\CoalesceAssignDemo::$b (list<int>|null) does not accept list<string>.',
32,
],
[
'Property Bug15228\\CoalesceAssignDemo::$b (list<int>|null) does not accept list<string>.',
38,
],
[
'Static property Bug15228\\StaticCoalesceAssignDemo::$a (int|null) does not accept string.',
60,
],
[
'Static property Bug15228\\StaticCoalesceAssignDemo::$b (list<int>|null) does not accept list<string>.',
65,
],
[
'Property Bug15228\\OffsetCoalesceAssignDemo::$a (array<string, int>) does not accept array<string, int|string>.',
80,
],
[
'Property Bug15228\\OtherAssignOpsDemo::$a (int|null) does not accept string.',
98,
],
[
'Property Bug15228\\OtherAssignOpsDemo::$a (int|null) does not accept string.',
103,
],
]);
}

}
115 changes: 115 additions & 0 deletions tests/PHPStan/Rules/Properties/data/bug-15228.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php declare(strict_types = 1);

namespace Bug15228;

class CoalesceAssignDemo
{
public ?int $a = null;

/** @var list<int>|null */
public $b;

/** @return list<string> */
private function strings(): array
{
return ['x'];
}

public function coalesceInvalid(): void
{
$this->a ??= 'boom';
}

public function ifInvalid(): void
{
if (null === $this->a) {
$this->a = 'boom';
}
}

public function coalesceValid(): void
{
$this->b = $this->strings();
$this->b ??= [1, 2];
}

public function ifValid(): void
{
$this->b = $this->strings();
if (null === $this->b) {
$this->b = [1, 2];
}
}
}

class StaticCoalesceAssignDemo
{
public static ?int $a = null;

/** @var list<int>|null */
public static $b;

/** @return list<string> */
private static function strings(): array
{
return ['x'];
}

public static function coalesceInvalid(): void
{
self::$a ??= 'boom';
}

public static function coalesceValid(): void
{
self::$b = self::strings();
self::$b ??= [1, 2];
}
}

class OffsetCoalesceAssignDemo
{
/** @var array<string, int> */
public array $a = [];

/** @var array<string, list<int>|null> */
public array $b = [];

public function coalesceInvalid(): void
{
$this->a['x'] ??= 'boom';
}

public function coalesceValid(): void
{
$this->b['x'] ??= [1, 2];
}
}

class OtherAssignOpsDemo
{
public ?int $a = null;

/** @var int<0, 10> */
public int $r = 0;

public function concat(): void
{
$this->a .= 'x';
}

public function concatExpanded(): void
{
$this->a = $this->a . 'x';
}

public function plus(): void
{
$this->r += 5;
}

public function plusExpanded(): void
{
$this->r = $this->r + 5;
}
}
Loading
Loading