Skip to content

Commit c168155

Browse files
committed
Don't loose known offset-values in array_merge()
1 parent 243d098 commit c168155

File tree

4 files changed

+72
-21
lines changed

4 files changed

+72
-21
lines changed

src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use PHPStan\Reflection\FunctionReflection;
99
use PHPStan\TrinaryLogic;
1010
use PHPStan\Type\Accessory\AccessoryArrayListType;
11+
use PHPStan\Type\Accessory\HasOffsetType;
1112
use PHPStan\Type\Accessory\NonEmptyArrayType;
1213
use PHPStan\Type\ArrayType;
1314
use PHPStan\Type\Constant\ConstantArrayType;
@@ -73,26 +74,40 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
7374
static fn (Type $argType) => $argType->isConstantArray(),
7475
);
7576

77+
$nonOptionalConstKeys = [];
78+
$newArrayBuilder = null;
7679
if ($allConstant->yes()) {
7780
$newArrayBuilder = ConstantArrayTypeBuilder::createEmpty();
78-
foreach ($argTypes as $argType) {
79-
/** @var array<int|string, ConstantIntegerType|ConstantStringType> $keyTypes */
80-
$keyTypes = [];
81-
foreach ($argType->getConstantArrays() as $constantArray) {
82-
foreach ($constantArray->getKeyTypes() as $keyType) {
83-
$keyTypes[$keyType->getValue()] = $keyType;
81+
}
82+
foreach ($argTypes as $argType) {
83+
/** @var array<int|string, ConstantIntegerType|ConstantStringType> $keyTypes */
84+
$keyTypes = [];
85+
foreach ($argType->getConstantArrays() as $constantArray) {
86+
foreach ($constantArray->getKeyTypes() as $i => $keyType) {
87+
$keyTypes[$keyType->getValue()] = $keyType;
88+
89+
if ($constantArray->isOptionalKey($i)) {
90+
continue;
8491
}
85-
}
8692

87-
foreach ($keyTypes as $keyType) {
88-
$newArrayBuilder->setOffsetValueType(
89-
$keyType instanceof ConstantIntegerType ? null : $keyType,
90-
$argType->getOffsetValueType($keyType),
91-
!$argType->hasOffsetValueType($keyType)->yes(),
92-
);
93+
$nonOptionalConstKeys[] = $keyType;
9394
}
9495
}
9596

97+
if ($newArrayBuilder === null) {
98+
continue;
99+
}
100+
101+
foreach ($keyTypes as $keyType) {
102+
$newArrayBuilder->setOffsetValueType(
103+
$keyType instanceof ConstantIntegerType ? null : $keyType,
104+
$argType->getOffsetValueType($keyType),
105+
!$argType->hasOffsetValueType($keyType)->yes(),
106+
);
107+
}
108+
}
109+
110+
if ($newArrayBuilder !== null) {
96111
return $newArrayBuilder->getArray();
97112
}
98113

@@ -121,6 +136,11 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
121136
return new ConstantArrayType([], []);
122137
}
123138

139+
$offsetTypes = [];
140+
foreach ($nonOptionalConstKeys as $constKey) {
141+
$offsetTypes[] = new HasOffsetType($constKey);
142+
}
143+
124144
$arrayType = new ArrayType(
125145
$keyType,
126146
TypeCombinator::union(...$valueTypes),
@@ -132,6 +152,9 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
132152
if ($isList) {
133153
$arrayType = TypeCombinator::intersect($arrayType, new AccessoryArrayListType());
134154
}
155+
if ($offsetTypes !== []) {
156+
$arrayType = TypeCombinator::intersect($arrayType, ...$offsetTypes);
157+
}
135158

136159
return $arrayType;
137160
}

tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4697,11 +4697,11 @@ public static function dataArrayFunctions(): array
46974697
'array_merge($generalStringKeys, $generalDateTimeValues)',
46984698
],
46994699
[
4700-
'non-empty-array<1|string, int|stdClass>',
4700+
"non-empty-array<1|string, int|stdClass>&hasOffset('foo')&hasOffset(1)",
47014701
'array_merge($generalStringKeys, $stringOrIntegerKeys)',
47024702
],
47034703
[
4704-
'non-empty-array<1|string, int|stdClass>',
4704+
"non-empty-array<1|string, int|stdClass>&hasOffset('foo')&hasOffset(1)",
47054705
'array_merge($stringOrIntegerKeys, $generalStringKeys)',
47064706
],
47074707
[
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
namespace ArrayMergeConstNonConst;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
function doFoo(array $post): void {
8+
assertType("non-empty-array&hasOffset('a')&hasOffset('b')", array_merge(['a' => 1, 'b' => false], $post));
9+
}
10+
11+
function doBar(array $array): void {
12+
assertType("non-empty-array&hasOffset('a')&hasOffset('b')", array_merge($array, ['a' => 1, 'b' => false]));
13+
}
14+
15+
function doFooBar(array $array): void {
16+
assertType("non-empty-array&hasOffset('a')&hasOffset('b')&hasOffset('c')", array_merge(['c' => 'd'], $array, ['a' => 1, 'b' => false, 'c' => 'e']));
17+
}
18+
19+
function doFooInts(array $array): void {
20+
assertType("non-empty-array&hasOffset('a')&hasOffset('c')&hasOffset(1)&hasOffset(3)", array_merge([1 => 'd'], $array, ['a' => 1, 3 => false, 'c' => 'e']));
21+
}
22+
23+
/**
24+
* @param array<string> $array
25+
*/
26+
function floatKey(array $array): void {
27+
assertType("non-empty-array<string>&hasOffset('a')&hasOffset('c')&hasOffset(3)&hasOffset(4)", array_merge([4.23 => 'd'], $array, ['a' => '1', 3 => 'false', 'c' => 'e']));
28+
}

tests/PHPStan/Analyser/nsrt/bug-2911.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,23 +49,23 @@ public function __construct(MutatorConfig $config)
4949
private function getResultSettings(array $settings): array
5050
{
5151
$settings = array_merge(self::DEFAULT_SETTINGS, $settings);
52-
assertType('non-empty-array<string, mixed>', $settings);
52+
assertType("non-empty-array<string, mixed>&hasOffset('limit')&hasOffset('remove')", $settings);
5353

5454
if (!is_string($settings['remove'])) {
5555
throw $this->configException($settings, 'remove');
5656
}
5757

58-
assertType("non-empty-array<string, mixed>&hasOffsetValue('remove', string)", $settings);
58+
assertType("non-empty-array<string, mixed>&hasOffset('limit')&hasOffsetValue('remove', string)", $settings);
5959

6060
$settings['remove'] = strtolower($settings['remove']);
6161

62-
assertType("non-empty-array<string, mixed>&hasOffsetValue('remove', lowercase-string)", $settings);
62+
assertType("non-empty-array<string, mixed>&hasOffset('limit')&hasOffsetValue('remove', lowercase-string)", $settings);
6363

6464
if (!in_array($settings['remove'], ['first', 'last', 'all'], true)) {
6565
throw $this->configException($settings, 'remove');
6666
}
6767

68-
assertType("non-empty-array<string, mixed>&hasOffsetValue('remove', 'all'|'first'|'last')", $settings);
68+
assertType("non-empty-array<string, mixed>&hasOffset('limit')&hasOffsetValue('remove', 'all'|'first'|'last')", $settings);
6969

7070
if (!is_numeric($settings['limit']) || $settings['limit'] < 1) {
7171
throw $this->configException($settings, 'limit');
@@ -110,13 +110,13 @@ private function getResultSettings(array $settings): array
110110
{
111111
$settings = array_merge(self::DEFAULT_SETTINGS, $settings);
112112

113-
assertType('non-empty-array<string, mixed>', $settings);
113+
assertType("non-empty-array<string, mixed>&hasOffset('limit')&hasOffset('remove')", $settings);
114114

115115
if (!is_string($settings['remove'])) {
116116
throw new Exception();
117117
}
118118

119-
assertType("non-empty-array<string, mixed>&hasOffsetValue('remove', string)", $settings);
119+
assertType("non-empty-array<string, mixed>&hasOffset('limit')&hasOffsetValue('remove', string)", $settings);
120120

121121
if (!is_int($settings['limit'])) {
122122
throw new Exception();

0 commit comments

Comments
 (0)