Skip to content

Commit e8509b7

Browse files
jlherrenondrejmirtes
authored andcommitted
Add return type extension for array_column()
1 parent ad917b7 commit e8509b7

File tree

5 files changed

+278
-0
lines changed

5 files changed

+278
-0
lines changed

conf/config.neon

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -892,6 +892,11 @@ services:
892892
tags:
893893
- phpstan.broker.dynamicFunctionReturnTypeExtension
894894

895+
-
896+
class: PHPStan\Type\Php\ArrayColumnFunctionReturnTypeExtension
897+
tags:
898+
- phpstan.broker.dynamicFunctionReturnTypeExtension
899+
895900
-
896901
class: PHPStan\Type\Php\ArrayCombineFunctionReturnTypeExtension
897902
tags:

src/TrinaryLogic.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ public function or(self ...$operands): self
9292

9393
public static function extremeIdentity(self ...$operands): self
9494
{
95+
if ($operands === []) {
96+
throw new ShouldNotHappenException();
97+
}
9598
$operandValues = array_column($operands, 'value');
9699
$min = min($operandValues);
97100
$max = max($operandValues);
@@ -100,6 +103,9 @@ public static function extremeIdentity(self ...$operands): self
100103

101104
public static function maxMin(self ...$operands): self
102105
{
106+
if ($operands === []) {
107+
throw new ShouldNotHappenException();
108+
}
103109
$operandValues = array_column($operands, 'value');
104110
return self::create(max($operandValues) > 0 ? max($operandValues) : min($operandValues));
105111
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Php;
4+
5+
use PhpParser\Node\Expr\FuncCall;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\Reflection\FunctionReflection;
8+
use PHPStan\Reflection\ParametersAcceptorSelector;
9+
use PHPStan\ShouldNotHappenException;
10+
use PHPStan\TrinaryLogic;
11+
use PHPStan\Type\Accessory\NonEmptyArrayType;
12+
use PHPStan\Type\ArrayType;
13+
use PHPStan\Type\Constant\ConstantArrayType;
14+
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
15+
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
16+
use PHPStan\Type\IntegerType;
17+
use PHPStan\Type\MixedType;
18+
use PHPStan\Type\NeverType;
19+
use PHPStan\Type\Type;
20+
use PHPStan\Type\TypeCombinator;
21+
use PHPStan\Type\TypeUtils;
22+
use function count;
23+
24+
class ArrayColumnFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension
25+
{
26+
27+
public function isFunctionSupported(FunctionReflection $functionReflection): bool
28+
{
29+
return $functionReflection->getName() === 'array_column';
30+
}
31+
32+
public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type
33+
{
34+
$numArgs = count($functionCall->getArgs());
35+
if ($numArgs < 2) {
36+
return ParametersAcceptorSelector::selectSingle($functionReflection->getVariants())->getReturnType();
37+
}
38+
39+
$arrayType = $scope->getType($functionCall->getArgs()[0]->value);
40+
$columnType = $scope->getType($functionCall->getArgs()[1]->value);
41+
$indexType = $numArgs >= 3 ? $scope->getType($functionCall->getArgs()[2]->value) : null;
42+
43+
$constantArrayTypes = TypeUtils::getConstantArrays($arrayType);
44+
if (count($constantArrayTypes) === 1) {
45+
$type = $this->handleConstantArray($constantArrayTypes[0], $columnType, $indexType, $scope);
46+
if ($type !== null) {
47+
return $type;
48+
}
49+
}
50+
51+
return $this->handleAnyArray($arrayType, $columnType, $indexType, $scope);
52+
}
53+
54+
private function handleAnyArray(Type $arrayType, Type $columnType, ?Type $indexType, Scope $scope): Type
55+
{
56+
$iterableAtLeastOnce = $arrayType->isIterableAtLeastOnce();
57+
if ($iterableAtLeastOnce->no()) {
58+
return new ConstantArrayType([], []);
59+
}
60+
61+
$iterableValueType = $arrayType->getIterableValueType();
62+
$returnValueType = $this->getOffsetOrProperty($iterableValueType, $columnType, $scope, false);
63+
64+
if ($returnValueType === null) {
65+
$returnValueType = $this->getOffsetOrProperty($iterableValueType, $columnType, $scope, true);
66+
$iterableAtLeastOnce = TrinaryLogic::createMaybe();
67+
if ($returnValueType === null) {
68+
throw new ShouldNotHappenException();
69+
}
70+
}
71+
72+
if ($returnValueType instanceof NeverType) {
73+
return new ConstantArrayType([], []);
74+
}
75+
76+
if ($indexType !== null) {
77+
$type = $this->getOffsetOrProperty($iterableValueType, $indexType, $scope, false);
78+
if ($type !== null) {
79+
$returnKeyType = $type;
80+
} else {
81+
$type = $this->getOffsetOrProperty($iterableValueType, $indexType, $scope, true);
82+
if ($type !== null) {
83+
$returnKeyType = TypeCombinator::union($type, new IntegerType());
84+
} else {
85+
$returnKeyType = new IntegerType();
86+
}
87+
}
88+
} else {
89+
$returnKeyType = new IntegerType();
90+
}
91+
92+
$returnType = new ArrayType($returnKeyType, $returnValueType);
93+
94+
if ($iterableAtLeastOnce->yes()) {
95+
$returnType = TypeCombinator::intersect($returnType, new NonEmptyArrayType());
96+
}
97+
98+
return $returnType;
99+
}
100+
101+
private function handleConstantArray(ConstantArrayType $arrayType, Type $columnType, ?Type $indexType, Scope $scope): ?Type
102+
{
103+
$builder = ConstantArrayTypeBuilder::createEmpty();
104+
105+
foreach ($arrayType->getValueTypes() as $iterableValueType) {
106+
$valueType = $this->getOffsetOrProperty($iterableValueType, $columnType, $scope, false);
107+
if ($valueType === null) {
108+
return null;
109+
}
110+
111+
if ($indexType !== null) {
112+
$type = $this->getOffsetOrProperty($iterableValueType, $indexType, $scope, false);
113+
if ($type !== null) {
114+
$keyType = $type;
115+
} else {
116+
$type = $this->getOffsetOrProperty($iterableValueType, $indexType, $scope, true);
117+
if ($type !== null) {
118+
$keyType = TypeCombinator::union($type, new IntegerType());
119+
} else {
120+
$keyType = null;
121+
}
122+
}
123+
} else {
124+
$keyType = null;
125+
}
126+
127+
$builder->setOffsetValueType($keyType, $valueType);
128+
}
129+
130+
return $builder->getArray();
131+
}
132+
133+
private function getOffsetOrProperty(Type $type, Type $offsetOrProperty, Scope $scope, bool $allowMaybe): ?Type
134+
{
135+
$returnTypes = [];
136+
137+
if (!$type->canAccessProperties()->no()) {
138+
$propertyTypes = TypeUtils::getConstantStrings($offsetOrProperty);
139+
if ($propertyTypes === []) {
140+
return new MixedType();
141+
}
142+
foreach ($propertyTypes as $propertyType) {
143+
$propertyName = $propertyType->getValue();
144+
$hasProperty = $type->hasProperty($propertyName);
145+
if ($hasProperty->maybe()) {
146+
return $allowMaybe ? new MixedType() : null;
147+
}
148+
if (!$hasProperty->yes()) {
149+
continue;
150+
}
151+
152+
$returnTypes[] = $type->getProperty($propertyName, $scope)->getReadableType();
153+
}
154+
}
155+
156+
if ($type->isOffsetAccessible()->yes()) {
157+
$hasOffset = $type->hasOffsetValueType($offsetOrProperty);
158+
if (!$allowMaybe && $hasOffset->maybe()) {
159+
return null;
160+
}
161+
if (!$hasOffset->no()) {
162+
$returnTypes[] = $type->getOffsetValueType($offsetOrProperty);
163+
}
164+
}
165+
166+
if ($returnTypes === []) {
167+
return new NeverType();
168+
}
169+
170+
return TypeCombinator::union(...$returnTypes);
171+
}
172+
173+
}

tests/PHPStan/Analyser/NodeScopeResolverTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,8 @@ public function dataFileAsserts(): iterable
627627
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-6399.php');
628628
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-4357.php');
629629
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-5817.php');
630+
631+
yield from $this->gatherAssertTypes(__DIR__ . '/data/array-column.php');
630632
}
631633

632634
/**
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
namespace ArrayColumn;
4+
5+
use DOMElement;
6+
use function PHPStan\Testing\assertType;
7+
8+
function testArrays(array $array): void
9+
{
10+
/** @var array<int, array<string, string>> $array */
11+
assertType('array<int, string>', array_column($array, 'column'));
12+
assertType('array<int|string, string>', array_column($array, 'column', 'key'));
13+
14+
/** @var non-empty-array<int, array<string, string>> $array */
15+
// Note: Array may still be empty!
16+
assertType('array<int, string>', array_column($array, 'column'));
17+
18+
/** @var array{} $array */
19+
assertType('array{}', array_column($array, 'column'));
20+
assertType('array{}', array_column($array, 'column', 'key'));
21+
}
22+
23+
function testConstantArrays(array $array): void
24+
{
25+
/** @var array<int, array{column: string, key: string}> $array */
26+
assertType('array<int, string>', array_column($array, 'column'));
27+
assertType('array<string, string>', array_column($array, 'column', 'key'));
28+
29+
/** @var array<int, array{column: string, key: string}> $array */
30+
assertType('array{}', array_column($array, 'foo'));
31+
assertType('array{}', array_column($array, 'foo', 'key'));
32+
33+
/** @var array{array{column: string, key: 'bar'}} $array */
34+
assertType("array{string}", array_column($array, 'column'));
35+
assertType("array{bar: string}", array_column($array, 'column', 'key'));
36+
37+
/** @var array{array{column: string, key: string}} $array */
38+
assertType("non-empty-array<string, string>", array_column($array, 'column', 'key'));
39+
40+
/** @var array<int, array{column?: 'foo', key?: 'bar'}> $array */
41+
assertType("array<int, 'foo'>", array_column($array, 'column'));
42+
assertType("array<'bar'|int, 'foo'>", array_column($array, 'column', 'key'));
43+
44+
/** @var array<int, array{column1: string, column2: bool}> $array */
45+
assertType('array<int, bool|string>', array_column($array, mt_rand(0, 1) === 0 ? 'column1' : 'column2'));
46+
47+
/** @var non-empty-array<int, array{column: string, key: string}> $array */
48+
assertType('non-empty-array<int, string>', array_column($array, 'column'));
49+
assertType('non-empty-array<string, string>', array_column($array, 'column', 'key'));
50+
}
51+
52+
function testImprecise(array $array): void {
53+
// These cases aren't handled precisely and will return non-constant arrays.
54+
55+
/** @var array{array{column?: 'foo', key: 'bar'}} $array */
56+
assertType("array<int, 'foo'>", array_column($array, 'column'));
57+
assertType("array<'bar', 'foo'>", array_column($array, 'column', 'key'));
58+
59+
/** @var array{array{column: 'foo', key?: 'bar'}} $array */
60+
assertType("non-empty-array<'bar'|int, 'foo'>", array_column($array, 'column', 'key'));
61+
62+
/** @var array{array{column: 'foo', key: 'bar'}}|array<int, array<string, string>> $array */
63+
assertType('array<int, string>', array_column($array, 'column'));
64+
assertType('array<int|string, string>', array_column($array, 'column', 'key'));
65+
66+
/** @var array{0?: array{column: 'foo', key: 'bar'}} $array */
67+
assertType("array<int, 'foo'>", array_column($array, 'column'));
68+
assertType("array<'bar', 'foo'>", array_column($array, 'column', 'key'));
69+
}
70+
71+
function testObjects(array $array): void {
72+
/** @var array<int, DOMElement> $array */
73+
assertType('array<int, string>', array_column($array, 'nodeName'));
74+
assertType('array<string, string>', array_column($array, 'nodeName', 'tagName'));
75+
assertType('array<int, mixed>', array_column($array, 'foo'));
76+
assertType('array<string, mixed>', array_column($array, 'foo', 'tagName'));
77+
assertType('array<string>', array_column($array, 'nodeName', 'foo'));
78+
79+
/** @var non-empty-array<int, DOMElement> $array */
80+
assertType('non-empty-array<int, string>', array_column($array, 'nodeName'));
81+
assertType('non-empty-array<string, string>', array_column($array, 'nodeName', 'tagName'));
82+
assertType('array<int, mixed>', array_column($array, 'foo'));
83+
assertType('array<string, mixed>', array_column($array, 'foo', 'tagName'));
84+
assertType('non-empty-array<string>', array_column($array, 'nodeName', 'foo'));
85+
86+
/** @var array{DOMElement} $array */
87+
assertType('array{string}', array_column($array, 'nodeName'));
88+
assertType('non-empty-array<string, string>', array_column($array, 'nodeName', 'tagName'));
89+
assertType('array<int, mixed>', array_column($array, 'foo'));
90+
assertType('array<string, mixed>', array_column($array, 'foo', 'tagName'));
91+
assertType('non-empty-array<int|string, string>', array_column($array, 'nodeName', 'foo'));
92+
}

0 commit comments

Comments
 (0)