Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

fix null coalesce false positive for multi-dimensional array in loop #4475

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
ondrejmirtes merged 2 commits into phpstan:2.1.x from schlndh:fix-nullCoalesceFalsePositive
Oct 24, 2025

Conversation

@schlndh
Copy link
Contributor

@schlndh schlndh commented Oct 24, 2025

Fixes: https://phpstan.org/r/9e10fb00-443f-4c05-9506-da40bf191f8e

The bug is triggered the second time the loop is processed on line 17 of the test file in produceArrayDimFetchAssignValueToWrite:

$valueToWrite = $offsetValueType->setExistingOffsetValueType($offsetType, $valueToWrite);

Original $valueToWrite: array{foo: 5, bar: 5, amount?: 0.0}
$offserValueType: non-empty-array<int, array{foo: 5, bar: 5, amount: 0.0}|array{foo: 5}>

The original version of setExistingOffsetValueType disregards optional keys, so it results in new $valueTypeToWrite: non-empty-array<int, array{foo: 5, bar: 5, amount: 0.0}>

{
$errors = $this->runAnalyse(__DIR__ . '/data/bug-7903.php');
$this->assertCount(23, $errors);
$this->assertCount(24, $errors);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm ignoring this change. This test was added as a performance regression test (I'm not sure if/how that works, or why it makes assertions about the errors in the first place) and it's extremely hard to reason about the code. So I'm assuming that the reported errors don't matter.

Fundamentally, the array handling in PHPStan has a set of bugs and as a result of this PR it will have a slightly different (hopefully smaller) set of bugs. But there can definitely be cases where the new code causes a regression.

If you want to see the difference detected in the test for yourself here are the errors:

Previous:

115: Offset 'oooo_yyyy' might not exist on array{ccccc_rrrrr: 0, ccccc_yyyy: 0, oooo_rrrrr: float, oooo_yyyy: 0, ssss: 0, ssss_next: 0}|array{ccccc_rrrrr: 0, ccccc_yyyy: 0, oooo_rrrrr: float, oooo_yyyy: float, ssss: float, ssss_next: float}|array{oooo_rrrrr: float}.
125: Offset 'oooo_yyyy' might not exist on array{ccccc_rrrrr: 0, ccccc_yyyy: 0, oooo_rrrrr: float, oooo_yyyy: 0|float, ssss: 0|float, ssss_next: 0|float}|array{oooo_rrrrr: float}.
130: Offset 'oooo_yyyy' might not exist on array{ccccc_rrrrr: 0, ccccc_yyyy: 0, oooo_rrrrr: float, oooo_yyyy: 0|float, ssss: 0|float, ssss_next: 0|float}|array{oooo_rrrrr: float}.
135: Offset 'oooo_yyyy' might not exist on array{ccccc_rrrrr: 0, ccccc_yyyy: 0, oooo_rrrrr: float, oooo_yyyy: 0|float, ssss: 0|float, ssss_next: 0|float}|array{oooo_rrrrr: float}.
169: Offset 'ccccc_rrrrr' might not exist on array{ccccc_rrrrr: 0|float, ccccc_yyyy: 0|float, oooo_rrrrr: 0|float, oooo_yyyy: 0|float, ssss: 0|float, ssss_next: 0|float}|array{oooo_rrrrr: float}.
170: Offset 'ccccc_yyyy' might not exist on array{ccccc_rrrrr: float, ccccc_yyyy: 0|float, oooo_rrrrr: 0|float, oooo_yyyy: 0|float, ssss: 0|float, ssss_next: 0|float}|array{oooo_rrrrr?: float, ccccc_rrrrr: float}.
174: Offset 'ccccc_yyyy' might not exist on array{ccccc_rrrrr: float, ccccc_yyyy: 0|float, oooo_rrrrr: 0|float, oooo_yyyy: 0|float, ssss: 0|float, ssss_next: 0|float}|array{oooo_rrrrr?: float, ccccc_rrrrr: float}.
178: Offset 'ccccc_yyyy' might not exist on array{ccccc_rrrrr: float, ccccc_yyyy: 0|float, oooo_rrrrr: 0|float, oooo_yyyy: 0|float, ssss: 0|float, ssss_next: 0|float}|array{oooo_rrrrr: float, ccccc_rrrrr: float}.
182: Offset 'ccccc_yyyy' might not exist on array{ccccc_rrrrr: 0, ccccc_yyyy: 0|float, oooo_rrrrr: 0, oooo_yyyy: 0|float, ssss: 0, ssss_next: 0}|array{oooo_yyyy: float}.
184: Offset 'ccccc_yyyy' might not exist on array{ccccc_rrrrr: 0, ccccc_yyyy: 0|float, oooo_rrrrr: 0, oooo_yyyy: 0|float, ssss: 0, ssss_next: 0}|array{oooo_yyyy: float}.
194: Offset 'ccccc_yyyy' might not exist on array{ccccc_rrrrr: 0|float, ccccc_yyyy: 0|float, oooo_rrrrr: 0|float, oooo_yyyy: 0|float, ssss: 0|float, ssss_next: 0|float}|array{oooo_rrrrr: float}.
195: Offset 'ccccc_yyyy' might not exist on array{ccccc_rrrrr: 0, ccccc_yyyy: 0|float, oooo_rrrrr: 0, oooo_yyyy: 0|float, ssss: 0, ssss_next: 0}|array{oooo_yyyy: float}.
198: Offset 'oooo_yyyy' might not exist on array{ccccc_rrrrr: 0|float, ccccc_yyyy: 0|float, oooo_rrrrr: 0|float, oooo_yyyy: 0|float, ssss: 0|float, ssss_next: 0|float}|array{oooo_rrrrr: float}.
199: Offset 'ssss_next' might not exist on array{ccccc_rrrrr: 0|float, ccccc_yyyy: 0|float, oooo_rrrrr: 0|float, oooo_yyyy: 0|float, ssss: 0|float, ssss_next: 0|float}|array{oooo_rrrrr: float}.
203: Offset 'ccccc_yyyy' might not exist on array{ccccc_rrrrr: 0|float, ccccc_yyyy: 0|float, oooo_rrrrr: 0|float, oooo_yyyy: 0|float, ssss: 0|float, ssss_next: 0|float}|array{oooo_rrrrr: float}.
207: Offset 'oooo_yyyy' might not exist on array{ccccc_rrrrr: 0|float, ccccc_yyyy: 0|float, oooo_rrrrr: 0|float, oooo_yyyy: 0|float, ssss: 0|float, ssss_next: 0|float}|array{oooo_rrrrr: float}.
208: Offset 'ssss_next' might not exist on array{ccccc_rrrrr: 0|float, ccccc_yyyy: 0|float, oooo_rrrrr: 0|float, oooo_yyyy: 0|float, ssss: 0|float, ssss_next: 0|float}|array{oooo_rrrrr: float}.
212: Comparison operation ">" between 0 and 0 is always false.
213: Comparison operation ">" between 0 and 0 is always false.
214: Comparison operation ">" between 0 and 0 is always false.
215: Comparison operation ">" between 0 and 0 is always false.
229: Comparison operation ">" between 0 and 0 is always false.
230: Comparison operation ">" between 0 and 0 is always false.

New:

115: Offset 'oooo_yyyy' might not exist on array{ccccc_rrrrr: 0, ccccc_yyyy: 0, oooo_rrrrr: float, oooo_yyyy: 0, ssss: 0, ssss_next: 0}|array{ccccc_rrrrr: 0, ccccc_yyyy: 0, oooo_rrrrr: float, oooo_yyyy: float, ssss: float, ssss_next: float}|array{oooo_rrrrr: float, oooo_yyyy: float, ssss: float, ssss_next: float}|array{oooo_rrrrr: float}.
116: Offset 'ssss' might not exist on array{ccccc_rrrrr: 0, ccccc_yyyy: 0, oooo_rrrrr: float, oooo_yyyy: float, ssss: 0, ssss_next: 0}|array{ccccc_rrrrr: 0, ccccc_yyyy: 0, oooo_rrrrr: float, oooo_yyyy: float, ssss: float, ssss_next: float}|array{oooo_rrrrr: float, oooo_yyyy: float, ssss: float, ssss_next: float}|array{oooo_rrrrr: float, oooo_yyyy: float}.
117: Offset 'ssss_next' might not exist on array{ccccc_rrrrr: 0, ccccc_yyyy: 0, oooo_rrrrr: float, oooo_yyyy: float, ssss: float, ssss_next: 0|float}|array{oooo_rrrrr: float, oooo_yyyy: float, ssss: float, ssss_next?: float}.
125: Offset 'oooo_yyyy' might not exist on array{ccccc_rrrrr: 0, ccccc_yyyy: 0, oooo_rrrrr: float, oooo_yyyy?: 0|float, ssss?: 0|float, ssss_next?: 0|float}|array{oooo_rrrrr: float, oooo_yyyy?: float, ssss?: float, ssss_next?: float}.
126: Offset 'ssss' might not exist on array{ccccc_rrrrr: 0, ccccc_yyyy: 0, oooo_rrrrr: 0|float, oooo_yyyy: float, ssss?: 0|float, ssss_next?: 0|float}|array{oooo_rrrrr: float, oooo_yyyy: float, ssss?: float, ssss_next?: float}.
127: Offset 'ssss_next' might not exist on array{ccccc_rrrrr: 0, ccccc_yyyy: 0, oooo_rrrrr: 0|float, oooo_yyyy?: 0|float, ssss: float, ssss_next?: 0|float}|array{oooo_rrrrr: float, oooo_yyyy?: float, ssss: float, ssss_next?: float}.
130: Offset 'oooo_yyyy' might not exist on array{ccccc_rrrrr: 0, ccccc_yyyy: 0, oooo_rrrrr: float, oooo_yyyy?: 0|float, ssss?: 0|float, ssss_next?: 0|float}|array{oooo_rrrrr: float, oooo_yyyy?: float, ssss?: float, ssss_next?: float}.
131: Offset 'ssss' might not exist on array{ccccc_rrrrr: 0, ccccc_yyyy: 0, oooo_rrrrr: 0|float, oooo_yyyy: float, ssss?: 0|float, ssss_next?: 0|float}|array{oooo_rrrrr: float, oooo_yyyy: float, ssss?: float, ssss_next?: float}.
132: Offset 'ssss_next' might not exist on array{ccccc_rrrrr: 0, ccccc_yyyy: 0, oooo_rrrrr: 0|float, oooo_yyyy?: 0|float, ssss: float, ssss_next?: 0|float}|array{oooo_rrrrr: float, oooo_yyyy?: float, ssss: float, ssss_next?: float}.
135: Offset 'oooo_yyyy' might not exist on array{ccccc_rrrrr: 0, ccccc_yyyy: 0, oooo_rrrrr: float, oooo_yyyy?: 0|float, ssss?: 0|float, ssss_next?: 0|float}|array{oooo_rrrrr: float, oooo_yyyy?: float, ssss?: float, ssss_next?: float}.
136: Offset 'ssss' might not exist on array{ccccc_rrrrr: 0, ccccc_yyyy: 0, oooo_rrrrr: 0|float, oooo_yyyy: float, ssss?: 0|float, ssss_next?: 0|float}|array{oooo_rrrrr: float, oooo_yyyy: float, ssss?: float, ssss_next?: float}.
137: Offset 'ssss_next' might not exist on array{ccccc_rrrrr: 0, ccccc_yyyy: 0, oooo_rrrrr: 0|float, oooo_yyyy?: 0|float, ssss: float, ssss_next?: 0|float}|array{oooo_rrrrr: float, oooo_yyyy?: float, ssss: float, ssss_next?: float}.
170: Offset 'ccccc_yyyy' might not exist on array{ccccc_rrrrr: float, ccccc_yyyy?: 0|float, oooo_rrrrr?: 0|float, oooo_yyyy?: 0|float, ssss?: 0|float, ssss_next?: 0|float}.
172: Offset 'ccccc_yyyy' might not exist on array{ccccc_rrrrr: float, ccccc_yyyy?: float, oooo_rrrrr: 0|float, oooo_yyyy: 0|float, ssss: 0|float, ssss_next: 0|float}|array{ccccc_rrrrr: float, ccccc_yyyy?: float}.
174: Offset 'ccccc_yyyy' might not exist on array{ccccc_rrrrr: float, ccccc_yyyy?: 0|float, oooo_rrrrr?: 0|float, oooo_yyyy?: 0|float, ssss?: 0|float, ssss_next?: 0|float}.
176: Offset 'ccccc_yyyy' might not exist on array{ccccc_rrrrr: float, ccccc_yyyy: 0|float, oooo_rrrrr: 0|float, oooo_yyyy: 0|float, ssss: 0|float, ssss_next: 0|float}|array{ccccc_rrrrr: float}.
178: Offset 'ccccc_yyyy' might not exist on array{ccccc_rrrrr: float, ccccc_yyyy: 0|float, oooo_rrrrr: 0|float, oooo_yyyy: 0|float, ssss: 0|float, ssss_next: 0|float}|array{ccccc_rrrrr: float}.
200: Offset 'oooo_yyyy' might not exist on array{ccccc_rrrrr: 0, ccccc_yyyy: 0|float, oooo_rrrrr: 0, oooo_yyyy?: 0|float, ssss: 0, ssss_next: 0}|non-empty-array{oooo_yyyy?: float, ccccc_yyyy?: float}.
212: Comparison operation ">" between 0 and 0 is always false.
213: Comparison operation ">" between 0 and 0 is always false.
214: Comparison operation ">" between 0 and 0 is always false.
215: Comparison operation ">" between 0 and 0 is always false.
229: Comparison operation ">" between 0 and 0 is always false.
230: Comparison operation ">" between 0 and 0 is always false.

$itemMap[$x][$month]['foo'] ??= 5;
$itemMap[$x][$month]['bar'] ??= 5;

$itemMap[$x][$month]['amount'] ??= 0.0;
Copy link
Member

@ondrejmirtes ondrejmirtes Oct 24, 2025

Choose a reason for hiding this comment

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

The same test in tests/PHPStan/Analyser/nsrt/ with some assertType calls would be nice. Make sure it fails first 😊

schlndh reacted with thumbs up emoji
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Contributor Author

schlndh commented Oct 24, 2025

There is a regression in doctrine/orm integration test. I'm not sure why it worked before, but I suspect that it was a bug, because AFAIK PHPStan isn't able to understand that class key cannot be unset by this line.

 ------ ----------------------------------------------------------------------- 
 Line src/Internal/Hydration/AbstractHydrator.php 
 ------ ----------------------------------------------------------------------- 
 358 Method 
 Doctrine\ORM\Internal\Hydration\AbstractHydrator::gatherRowData() 
 should return array{data: array<array>, newObjects?: array<array{clas 
 s: ReflectionClass, args: array, obj: object}>, scalars?: array} but 
 returns array{data: array<non-empty-array>, newObjects: array<array{c 
 lass?: mixed, args: non-empty-array, obj: mixed}>, scalars?: non-empt 
 y-array}. 
 🪪 return.type 
 💡 Offset 'newObjects' (array<array{class: ReflectionClass, args: arr 
 ay, obj: object}>) does not accept type array<array{class?: mixed, ar 
 gs: non-empty-array, obj: mixed}>: Array might not have offset 'class 
 '. 
 ------ ----------------------------------------------------------------------- 

Copy link
Member

Make sure it's not something they already have in the baseline but the message changed slightly. I suspect it might be the case of the PMMP error as well.

Copy link
Contributor Author

schlndh commented Oct 24, 2025

I did check the baseline and there is no message about the return type there. I also confirmed that the class key is not optional in 2.1.x.

The PMMP error is not related to this PR because it also happens in PR 4474.

@ondrejmirtes ondrejmirtes merged commit 38a7dfd into phpstan:2.1.x Oct 24, 2025
540 of 552 checks passed
Copy link
Member

Thank you!

schlndh reacted with thumbs up emoji

@schlndh schlndh deleted the fix-nullCoalesceFalsePositive branch October 24, 2025 16:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

@ondrejmirtes ondrejmirtes ondrejmirtes requested changes

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

AltStyle によって変換されたページ (->オリジナル) /