Skip to content

Commit a5cd85b

Browse files
committed
Fix count()-based type narrowing on unions of constant array shapes
When narrowing a union of constant array shapes (e.g. `array{bool}|array{mixed, string|null, mixed}`) via count() comparisons, the narrowed type was applied via intersection with the original type. This failed to filter out non-matching union members because ConstantArrayType::isSuperTypeOf() uses open-shape semantics. Fix: use setAlwaysOverwriteTypes() when the type being narrowed is a constant array union, so the precisely-computed result from specifyTypesForCountFuncCall() directly replaces the original type instead of being intersected with it. This also improves precision for arrays with optional keys: when count === N is known, optional keys that can't be present at that count are now correctly removed from the narrowed type. Closes phpstan/phpstan#14301
1 parent 98a3159 commit a5cd85b

4 files changed

Lines changed: 56 additions & 3 deletions

File tree

src/Analyser/TypeSpecifier.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2586,6 +2586,9 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope
25862586

25872587
$specifiedTypes = $this->specifyTypesForCountFuncCall($unwrappedLeftExpr, $argType, $rightType, $context, $scope, $expr);
25882588
if ($specifiedTypes !== null) {
2589+
if ($argType->isConstantArray()->yes()) {
2590+
return $specifiedTypes->setAlwaysOverwriteTypes();
2591+
}
25892592
return $specifiedTypes;
25902593
}
25912594

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug14301;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
class Foo
8+
{
9+
/**
10+
* @param array{bool}|array{mixed, string|null, mixed} $row
11+
*/
12+
protected function testNotEquals(array $row): string
13+
{
14+
if (count($row) !== 1) {
15+
assertType('array{mixed, string|null, mixed}', $row);
16+
17+
[$field, $operator, $value] = $row;
18+
assertType('string|null', $operator);
19+
return $operator ?? '=';
20+
} else {
21+
assertType('array{bool}', $row);
22+
}
23+
24+
return '';
25+
}
26+
27+
/**
28+
* @param array{bool}|array{mixed, string|null, mixed} $row
29+
*/
30+
protected function testEquals(array $row): void
31+
{
32+
if (count($row) === 3) {
33+
assertType('array{mixed, string|null, mixed}', $row);
34+
} else {
35+
assertType('array{bool}', $row);
36+
}
37+
}
38+
39+
/**
40+
* @param array{bool}|array{mixed, string|null, mixed} $row
41+
*/
42+
protected function testEquals1(array $row): void
43+
{
44+
if (count($row) === 1) {
45+
assertType('array{bool}', $row);
46+
} else {
47+
assertType('array{mixed, string|null, mixed}', $row);
48+
}
49+
}
50+
}

tests/PHPStan/Analyser/nsrt/list-count.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,15 +291,15 @@ protected function testOptionalKeysInListsOfTaggedUnion($row): void
291291
}
292292

293293
if (count($row) === 1) {
294-
assertType('array{0: int, 1?: string|null}|array{string}', $row);
294+
assertType('array{int}|array{string}', $row);
295295
} else {
296296
assertType('array{int, string|null}', $row);
297297
}
298298

299299
if (count($row) === 2) {
300300
assertType('array{int, string|null}', $row);
301301
} else {
302-
assertType('array{0: int, 1?: string|null}|array{string}', $row);
302+
assertType('array{int}|array{string}', $row);
303303
}
304304

305305
if (count($row) === 3) {

tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ public function arrayIntRangeSize(): void
101101
}
102102

103103
if (count($x) === 1) {
104-
assertType("array{'xy'}|array{0: 'ab', 1?: 'xy'}", $x);
104+
assertType("array{'ab'}|array{'xy'}", $x);
105105
} else {
106106
assertType("array{}|array{0: 'ab', 1?: 'xy'}", $x);
107107
}

0 commit comments

Comments
 (0)