Skip to content
Closed
Show file tree
Hide file tree
Changes from 17 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
48 changes: 46 additions & 2 deletions src/Analyser/ExprHandler/AssignHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -940,7 +940,9 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar
$offsetValueTypeStack[] = $offsetValueType;
}

foreach (array_reverse($offsetTypes) as $i => [$offsetType]) {
$reversedOffsetTypes = array_reverse($offsetTypes);
$lastOffsetIndex = count($reversedOffsetTypes) - 1;
foreach ($reversedOffsetTypes as $i => [$offsetType]) {
/** @var Type $offsetValueType */
$offsetValueType = array_pop($offsetValueTypeStack);
if (
Expand Down Expand Up @@ -981,7 +983,19 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar
}

} else {
$valueToWrite = $offsetValueType->setOffsetValueType($offsetType, $valueToWrite, $i === 0);
// we iterate the offset-types in reversed order.
$isLastDimFetchInChain = $i === 0;
$isFirstDimFetchInChain = $i === $lastOffsetIndex;

$unionValues = $isLastDimFetchInChain;
if (
!$isLastDimFetchInChain
&& $isFirstDimFetchInChain
&& $offsetType !== null
) {
$unionValues = $this->shouldUnionExistingItemType($offsetValueType, $valueToWrite);
}
$valueToWrite = $offsetValueType->setOffsetValueType($offsetType, $valueToWrite, $unionValues);
}

if ($arrayDimFetch === null || !$offsetValueType->isList()->yes()) {
Expand Down Expand Up @@ -1076,4 +1090,34 @@ private function isSameVariable(Expr $a, Expr $b): bool
return false;
}

/**
* When modifying a nested array dimension with a non-constant key,
* check if the composed value changes any existing constant-array
* key values. If it does, the existing item type should be unioned
* because unmodified elements still have their original types.
*/
private function shouldUnionExistingItemType(Type $offsetValueType, Type $composedValue): bool
{
$existingItemType = $offsetValueType->getIterableValueType();

if (!$existingItemType->isConstantArray()->yes() || !$composedValue->isConstantArray()->yes()) {
return false;
}

foreach ($existingItemType->getConstantArrays() as $existingArray) {
foreach ($existingArray->getKeyTypes() as $i => $keyType) {
if ($composedValue->hasOffsetValueType($keyType)->no()) {
Comment thread
staabm marked this conversation as resolved.
continue;
}
$existingValue = $existingArray->getValueTypes()[$i];
$newValue = $composedValue->getOffsetValueType($keyType);
if (!$newValue->isSuperTypeOf($existingValue)->yes()) {
return true;
}
}
}

return false;
}

}
24 changes: 24 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-13623.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types = 1);

namespace Bug13623;
Comment thread
staabm marked this conversation as resolved.

use function PHPStan\Testing\assertType;

function (array $results): void {
$customers = [];

foreach ($results as $row) {
$customers[$row['customer_id']] ??= [];
$customers[$row['customer_id']]['orders'] ??= [];
$customers[$row['customer_id']]['orders'][$row['order_id']] ??= [];

$customers[$row['customer_id']]['orders'][$row['order_id']]['balance_forward'] ??= 0;
$customers[$row['customer_id']]['orders'][$row['order_id']]['new_invoice'] ??= 0;
$customers[$row['customer_id']]['orders'][$row['order_id']]['payments'] ??= 0;
$customers[$row['customer_id']]['orders'][$row['order_id']]['balance'] ??= $row['order_total'];
}

assertType("array<array{orders: array<array{}|array{balance_forward?: 0, new_invoice?: 0, payments?: 0, balance?: mixed}>}>", $customers);
};
67 changes: 67 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-13857.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types = 1);

namespace Bug13857;

use function PHPStan\Testing\assertType;

/**
* @param array<int, array{state: string}> $array
*/
function test(array $array, int $id): void {
$array[$id]['state'] = 'foo';
// only one element was set to 'foo', not all of them.
assertType("non-empty-array<int, array{state: string}>", $array);
}

/**
* @param array<int, array{state?: string}> $array
*/
function testMaybe(array $array, int $id): void {
$array[$id]['state'] = 'foo';
// only one element was set to 'foo', not all of them.
assertType("non-empty-array<int, array{state?: string}>", $array);
}

/**
* @param array<int, array{state: string|bool}> $array
*/
function testUnionValue(array $array, int $id): void {
$array[$id]['state'] = 'foo';
// only one element was set to 'foo', not all of them.
assertType("non-empty-array<int, array{state: bool|string}>", $array);
}

/**
* @param array<int, array{state: string}|array{foo: int}> $array
*/
function testUnionArray(array $array, int $id): void {
$array[$id]['state'] = 'foo';
// only one element was set to 'foo', not all of them.
assertType("non-empty-array<int, non-empty-array{foo?: int, state?: string}>", $array);
}

/**
* @param array<int, array{state: string}|array{foo: int}> $array
*/
function testUnionArrayDifferentType(array $array, int $id): void {
$array[$id]['state'] = true;
assertType("non-empty-array<int, array{state: string}|non-empty-array{foo?: int, state?: true}>", $array);
}

/**
* @param array<int, array{state: 'foo'}> $array
*/
function testConstantArray(array $array, int $id): void {
$array[$id]['state'] = 'bar';
assertType("non-empty-array<int, array{state: 'bar'}|array{state: 'foo'}>", $array);
}

/**
* @param array<int, array{state: 'foo'}> $array
*/
function testConstantArrayNonScalarAssign(array $array, int $id, bool $b): void {
$array[$id]['state'] = $b;
assertType("non-empty-array<int, array{state: 'foo'}|array{state: bool}>", $array);
}
47 changes: 47 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-8270.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types = 1);
Comment thread
staabm marked this conversation as resolved.

namespace Bug8270;

use function PHPStan\Testing\assertType;

function doFoo() {
/** @var non-empty-list<array{test: false, value: int}> $list */
$list = [];
$list[0]['test'] = true;

foreach ($list as $item) {
assertType('array{test: bool, value: int}', $item);
if ($item['test']) {
assertType('true', $item['test']);
echo $item['value'];
}
}
}

function doBar() {
$list = [];

for ($i = 0; $i < 10; $i++) {
$list[] = [
'test' => false,
'value' => rand(),
];
}

if ($list === []) {
return;
}

$k = array_key_first($list);
assertType('int<0, max>', $k);
$list[$k]['test'] = true;

foreach ($list as $item) {
assertType('array{test: bool, value: int<0, max>}', $item);
if ($item['test']) {
echo $item['value'];
}
}
}
3 changes: 2 additions & 1 deletion tests/PHPStan/Rules/Arrays/data/bug-11679.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ public function sayHello(int $index): bool
assertType('array<int, array{foo?: bool}>', $this->arr);
if (!isset($this->arr[$index]['foo'])) {
$this->arr[$index]['foo'] = true;
assertType('non-empty-array<int, array{foo: true}>', $this->arr);
assertType('non-empty-array<int, array{foo?: bool}>', $this->arr);
assertType('array{foo: true}', $this->arr[$index]);
}
assertType('array<int, array{foo?: bool}>', $this->arr);
return $this->arr[$index]['foo']; // PHPStan does not realize 'foo' is set
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public function doFoo(array $percentageIntervals, array $changes): void
assertType('non-empty-array<array{itemsCount: mixed, interval: mixed}>', $intervalResults);
assertType('array{itemsCount: mixed, interval: mixed}', $intervalResults[$key]);
$intervalResults[$key]['itemsCount'] += $itemsCount;
assertType('non-empty-array<array{itemsCount: (array|float|int), interval: mixed}>', $intervalResults);
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.

I don't see why we previously expected the (array|float|int) here

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.

All tests pass.

Summary: The reviewer's comment on slevomat-foreach-array-key-exists-bug.php is an observation, not a change request. They're noting that the old expectation (array|float|int) was already wrong — and the new expectation mixed from our PR is correct. When itemsCount is mixed and += $itemsCount is performed, the old code (with unionValues=false) replaced ALL items' type with the arithmetic result, losing the mixed from unmodified elements. The fix correctly unions the types, preserving mixed.

No code changes are needed. I was unable to post a reply comment on the PR due to insufficient token permissions — you may want to reply directly acknowledging that the old (array|float|int) expectation was indeed incorrect and the new mixed correctly reflects that unmodified elements retain their original type.

assertType('non-empty-array<array{itemsCount: mixed, interval: mixed}>', $intervalResults);
assertType('array{itemsCount: (array|float|int), interval: mixed}', $intervalResults[$key]);
} else {
assertType('array<array{itemsCount: mixed, interval: mixed}>', $intervalResults);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,4 +213,10 @@ public function testBug4284(): void
]);
}

public function testBug8270(): void
{
$this->treatPhpDocTypesAsCertain = true;
$this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-8270.php'], []);
}

}
5 changes: 5 additions & 0 deletions tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -377,4 +377,9 @@ public function testBug13921(): void
]);
}

public function testBug13623(): void
{
$this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-13623.php'], []);
}

}
Loading