Skip to content

Commit d260d4e

Browse files
phpstan-botondrejmirtes
authored andcommitted
Build an (unsealed) array shape in ArrayType::intersectKeyArray() when the other operand has known sealedness
- When `array_intersect_key()`'s first argument is a general `ArrayType` and the other operand is one or more constant array shapes with a definite sealedness (`isUnsealed()` is `yes` or `no`, i.e. bleeding edge), `ArrayType::intersectKeyArray()` now produces a `ConstantArrayType` with each shape key as an optional offset and the first array's value type, instead of degrading to `array<keys, value>`. - Unsealed extras of the other shape are carried over via `makeUnsealed()`, with the unsealed key narrowed to the first array's key type. - Keys that cannot intersect the first array's key type are dropped. - Non-bleeding-edge shapes (`isUnsealed()` is `maybe`) keep the previous general-array behavior, so the non-bleeding-edge path is unaffected. - Updated array-intersect-key.php expectations to the more precise shapes (e.g. `array{foo?: string}`, `array{17: string}`, `list{0?: string, 1?: string}`).
1 parent dcc3960 commit d260d4e

3 files changed

Lines changed: 105 additions & 7 deletions

File tree

src/Type/ArrayType.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,9 +503,57 @@ public function intersectKeyArray(Type $otherArraysType): Type
503503
return $this;
504504
}
505505

506+
$constantArrays = $otherArraysType->getConstantArrays();
507+
if (count($constantArrays) > 0) {
508+
// When the other operand is one or more array shapes with a known
509+
// sealedness, the result is a (possibly unsealed) array shape too:
510+
// it can only contain the keys present in those shapes, each
511+
// optional because the general first array may or may not have it.
512+
$allSealednessKnown = true;
513+
foreach ($constantArrays as $constantArray) {
514+
if ($constantArray->isUnsealed()->maybe()) {
515+
$allSealednessKnown = false;
516+
break;
517+
}
518+
}
519+
520+
if ($allSealednessKnown) {
521+
$results = [];
522+
foreach ($constantArrays as $constantArray) {
523+
$results[] = $this->intersectConstantArrayShape($constantArray);
524+
}
525+
526+
return TypeCombinator::union(...$results);
527+
}
528+
}
529+
506530
return $this->withTypes($otherArraysType->getIterableKeyType(), $this->getIterableValueType());
507531
}
508532

533+
private function intersectConstantArrayShape(ConstantArrayType $constantArray): Type
534+
{
535+
$builder = ConstantArrayTypeBuilder::createEmpty();
536+
537+
$valueType = $this->getIterableValueType();
538+
$keyType = $this->getIterableKeyType();
539+
foreach ($constantArray->getKeyTypes() as $shapeKeyType) {
540+
if (TypeCombinator::intersect($shapeKeyType, $keyType) instanceof NeverType) {
541+
continue;
542+
}
543+
$builder->setOffsetValueType($shapeKeyType, $valueType, true);
544+
}
545+
546+
$unsealed = $constantArray->getUnsealedTypes();
547+
if ($constantArray->isUnsealed()->yes() && $unsealed !== null) {
548+
$narrowedUnsealedKey = TypeCombinator::intersect($unsealed[0], $keyType);
549+
if (!$narrowedUnsealedKey instanceof NeverType) {
550+
$builder->makeUnsealed($narrowedUnsealedKey, $valueType);
551+
}
552+
}
553+
554+
return $builder->getArray();
555+
}
556+
509557
public function popArray(): Type
510558
{
511559
return $this;

tests/PHPStan/Analyser/nsrt/array-intersect-key.php

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public function nonEmpty(array $arr, array $arr2): void
1919
assertType('array<int, string>', array_intersect_key($arr, $arr2));
2020
assertType('array<int, string>', array_intersect_key($arr2, $arr));
2121
assertType('array{}', array_intersect_key($arr, []));
22-
assertType("array<'foo', string>", array_intersect_key($arr, ['foo' => 17]));
22+
assertType('array{foo?: string}', array_intersect_key($arr, ['foo' => 17]));
2323
}
2424

2525

@@ -34,7 +34,7 @@ public function normalArrays(array $arr, array $arr2, array $otherArrs): void
3434
assertType('array<int, string>', array_intersect_key($arr, $arr2));
3535
assertType('array<int, string>', array_intersect_key($arr2, $arr));
3636
assertType('array{}', array_intersect_key($arr, []));
37-
assertType("array<'foo', string>", array_intersect_key($arr, ['foo' => 17]));
37+
assertType('array{foo?: string}', array_intersect_key($arr, ['foo' => 17]));
3838

3939
/** @var array<int, string> $otherArrs */
4040
assertType('array<int, string>', array_intersect_key($arr, $otherArrs));
@@ -43,20 +43,20 @@ public function normalArrays(array $arr, array $arr2, array $otherArrs): void
4343
/** @var array<string, int> $otherArrs */
4444
assertType('array<string, string>', array_intersect_key($arr, $otherArrs));
4545
/** @var array<17, int> $otherArrs */
46-
assertType('array<17, string>', array_intersect_key($arr, $otherArrs));
46+
assertType('array{17?: string}', array_intersect_key($arr, $otherArrs));
4747
/** @var array<null, int> $otherArrs */
48-
assertType('array<\'\', string>', array_intersect_key($arr, $otherArrs));
48+
assertType('array{\'\'?: string}', array_intersect_key($arr, $otherArrs));
4949

5050
if (array_key_exists(17, $arr2)) {
51-
assertType('non-empty-array<17, string>&hasOffset(17)', array_intersect_key($arr2, [17 => 'bar']));
51+
assertType('array{17: string}', array_intersect_key($arr2, [17 => 'bar']));
5252
/** @var array<int, string> $otherArrs */
5353
assertType('array<int, string>', array_intersect_key($arr2, $otherArrs));
5454
/** @var array<string, int> $otherArrs */
5555
assertType('array{}', array_intersect_key($arr2, $otherArrs));
5656
}
5757

5858
if (array_key_exists(17, $arr2) && $arr2[17] === 'foo') {
59-
assertType("non-empty-array<17, string>&hasOffsetValue(17, 'foo')", array_intersect_key($arr2, [17 => 'bar']));
59+
assertType("array{17: 'foo'}", array_intersect_key($arr2, [17 => 'bar']));
6060
/** @var array<int, string> $otherArrs */
6161
assertType('array<int, string>', array_intersect_key($arr2, $otherArrs));
6262
/** @var array<string, int> $otherArrs */
@@ -79,7 +79,7 @@ public function arrayUnpacking(array $arrs, array $arrs2): void
7979
/** @param list<string> $arr */
8080
public function list(array $arr, array $otherArrs): void
8181
{
82-
assertType('list<string>', array_intersect_key($arr, ['foo', 'bar']));
82+
assertType('list{0?: string, 1?: string}', array_intersect_key($arr, ['foo', 'bar']));
8383
/** @var array<int, string> $otherArrs */
8484
assertType('array<int<0, max>, string>', array_intersect_key($arr, $otherArrs));
8585
/** @var array<string, int> $otherArrs */
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
namespace Bug14747;
4+
5+
use function array_intersect_key;
6+
use function PHPStan\Testing\assertType;
7+
8+
/** @return array<mixed> */
9+
function get_options(): array {
10+
return ['foo' => 'bar'];
11+
}
12+
13+
function test(): void {
14+
$options = get_options();
15+
16+
$o = array_intersect_key($options, ['a' => null, 'b' => null]);
17+
assertType('array{a?: mixed, b?: mixed}', $o);
18+
}
19+
20+
/**
21+
* @param array<int|string, string> $arr
22+
*/
23+
function literalShape(array $arr): void {
24+
assertType('array{foo?: string}', array_intersect_key($arr, ['foo' => 17]));
25+
assertType('array{a?: string, b?: string}', array_intersect_key($arr, ['a' => 1, 'b' => 2]));
26+
}
27+
28+
/**
29+
* @param array<int, string> $arr
30+
*/
31+
function keysOutsideRange(array $arr): void {
32+
// string keys cannot intersect int-keyed array
33+
assertType('array{}', array_intersect_key($arr, ['foo' => 17, 'bar' => 18]));
34+
}
35+
36+
/**
37+
* @param array<int|string, string> $arr
38+
*/
39+
function unionOfShapes(array $arr, bool $b): void {
40+
$other = $b ? ['a' => 1] : ['b' => 2, 'c' => 3];
41+
assertType('array{a?: string}|array{b?: string, c?: string}', array_intersect_key($arr, $other));
42+
}
43+
44+
/**
45+
* @param array<int|string, string> $arr
46+
* @param array{a: 1, ...<string, mixed>} $unsealed
47+
*/
48+
function unsealedShape(array $arr, array $unsealed): void {
49+
assertType('array{a?: string, ...<string, string>}', array_intersect_key($arr, $unsealed));
50+
}

0 commit comments

Comments
 (0)