Skip to content

Commit

Permalink
Add return type extension for array_column()
Browse files Browse the repository at this point in the history
  • Loading branch information
jlherren authored and ondrejmirtes committed Jan 29, 2022
1 parent ad917b7 commit e8509b7
Show file tree
Hide file tree
Showing 5 changed files with 278 additions and 0 deletions.
5 changes: 5 additions & 0 deletions conf/config.neon
Expand Up @@ -892,6 +892,11 @@ services:
tags:
- phpstan.broker.dynamicFunctionReturnTypeExtension

-
class: PHPStan\Type\Php\ArrayColumnFunctionReturnTypeExtension
tags:
- phpstan.broker.dynamicFunctionReturnTypeExtension

-
class: PHPStan\Type\Php\ArrayCombineFunctionReturnTypeExtension
tags:
Expand Down
6 changes: 6 additions & 0 deletions src/TrinaryLogic.php
Expand Up @@ -92,6 +92,9 @@ public function or(self ...$operands): self

public static function extremeIdentity(self ...$operands): self
{
if ($operands === []) {
throw new ShouldNotHappenException();
}
$operandValues = array_column($operands, 'value');
$min = min($operandValues);
$max = max($operandValues);
Expand All @@ -100,6 +103,9 @@ public static function extremeIdentity(self ...$operands): self

public static function maxMin(self ...$operands): self
{
if ($operands === []) {
throw new ShouldNotHappenException();
}
$operandValues = array_column($operands, 'value');
return self::create(max($operandValues) > 0 ? max($operandValues) : min($operandValues));
}
Expand Down
173 changes: 173 additions & 0 deletions src/Type/Php/ArrayColumnFunctionReturnTypeExtension.php
@@ -0,0 +1,173 @@
<?php declare(strict_types = 1);

namespace PHPStan\Type\Php;

use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\FunctionReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\ShouldNotHappenException;
use PHPStan\TrinaryLogic;
use PHPStan\Type\Accessory\NonEmptyArrayType;
use PHPStan\Type\ArrayType;
use PHPStan\Type\Constant\ConstantArrayType;
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
use PHPStan\Type\IntegerType;
use PHPStan\Type\MixedType;
use PHPStan\Type\NeverType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\TypeUtils;
use function count;

class ArrayColumnFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension
{

public function isFunctionSupported(FunctionReflection $functionReflection): bool
{
return $functionReflection->getName() === 'array_column';
}

public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type
{
$numArgs = count($functionCall->getArgs());
if ($numArgs < 2) {
return ParametersAcceptorSelector::selectSingle($functionReflection->getVariants())->getReturnType();
}

$arrayType = $scope->getType($functionCall->getArgs()[0]->value);
$columnType = $scope->getType($functionCall->getArgs()[1]->value);
$indexType = $numArgs >= 3 ? $scope->getType($functionCall->getArgs()[2]->value) : null;

$constantArrayTypes = TypeUtils::getConstantArrays($arrayType);
if (count($constantArrayTypes) === 1) {
$type = $this->handleConstantArray($constantArrayTypes[0], $columnType, $indexType, $scope);
if ($type !== null) {
return $type;
}
}

return $this->handleAnyArray($arrayType, $columnType, $indexType, $scope);
}

private function handleAnyArray(Type $arrayType, Type $columnType, ?Type $indexType, Scope $scope): Type
{
$iterableAtLeastOnce = $arrayType->isIterableAtLeastOnce();
if ($iterableAtLeastOnce->no()) {
return new ConstantArrayType([], []);
}

$iterableValueType = $arrayType->getIterableValueType();
$returnValueType = $this->getOffsetOrProperty($iterableValueType, $columnType, $scope, false);

if ($returnValueType === null) {
$returnValueType = $this->getOffsetOrProperty($iterableValueType, $columnType, $scope, true);
$iterableAtLeastOnce = TrinaryLogic::createMaybe();
if ($returnValueType === null) {
throw new ShouldNotHappenException();
}
}

if ($returnValueType instanceof NeverType) {
return new ConstantArrayType([], []);
}

if ($indexType !== null) {
$type = $this->getOffsetOrProperty($iterableValueType, $indexType, $scope, false);
if ($type !== null) {
$returnKeyType = $type;
} else {
$type = $this->getOffsetOrProperty($iterableValueType, $indexType, $scope, true);
if ($type !== null) {
$returnKeyType = TypeCombinator::union($type, new IntegerType());
} else {
$returnKeyType = new IntegerType();
}
}
} else {
$returnKeyType = new IntegerType();
}

$returnType = new ArrayType($returnKeyType, $returnValueType);

if ($iterableAtLeastOnce->yes()) {
$returnType = TypeCombinator::intersect($returnType, new NonEmptyArrayType());
}

return $returnType;
}

private function handleConstantArray(ConstantArrayType $arrayType, Type $columnType, ?Type $indexType, Scope $scope): ?Type
{
$builder = ConstantArrayTypeBuilder::createEmpty();

foreach ($arrayType->getValueTypes() as $iterableValueType) {
$valueType = $this->getOffsetOrProperty($iterableValueType, $columnType, $scope, false);
if ($valueType === null) {
return null;
}

if ($indexType !== null) {
$type = $this->getOffsetOrProperty($iterableValueType, $indexType, $scope, false);
if ($type !== null) {
$keyType = $type;
} else {
$type = $this->getOffsetOrProperty($iterableValueType, $indexType, $scope, true);
if ($type !== null) {
$keyType = TypeCombinator::union($type, new IntegerType());
} else {
$keyType = null;
}
}
} else {
$keyType = null;
}

$builder->setOffsetValueType($keyType, $valueType);
}

return $builder->getArray();
}

private function getOffsetOrProperty(Type $type, Type $offsetOrProperty, Scope $scope, bool $allowMaybe): ?Type
{
$returnTypes = [];

if (!$type->canAccessProperties()->no()) {
$propertyTypes = TypeUtils::getConstantStrings($offsetOrProperty);
if ($propertyTypes === []) {
return new MixedType();
}
foreach ($propertyTypes as $propertyType) {
$propertyName = $propertyType->getValue();
$hasProperty = $type->hasProperty($propertyName);
if ($hasProperty->maybe()) {
return $allowMaybe ? new MixedType() : null;
}
if (!$hasProperty->yes()) {
continue;
}

$returnTypes[] = $type->getProperty($propertyName, $scope)->getReadableType();
}
}

if ($type->isOffsetAccessible()->yes()) {
$hasOffset = $type->hasOffsetValueType($offsetOrProperty);
if (!$allowMaybe && $hasOffset->maybe()) {
return null;
}
if (!$hasOffset->no()) {
$returnTypes[] = $type->getOffsetValueType($offsetOrProperty);
}
}

if ($returnTypes === []) {
return new NeverType();
}

return TypeCombinator::union(...$returnTypes);
}

}
2 changes: 2 additions & 0 deletions tests/PHPStan/Analyser/NodeScopeResolverTest.php
Expand Up @@ -627,6 +627,8 @@ public function dataFileAsserts(): iterable
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-6399.php');
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-4357.php');
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-5817.php');

yield from $this->gatherAssertTypes(__DIR__ . '/data/array-column.php');
}

/**
Expand Down
92 changes: 92 additions & 0 deletions tests/PHPStan/Analyser/data/array-column.php
@@ -0,0 +1,92 @@
<?php

namespace ArrayColumn;

use DOMElement;
use function PHPStan\Testing\assertType;

function testArrays(array $array): void
{
/** @var array<int, array<string, string>> $array */
assertType('array<int, string>', array_column($array, 'column'));
assertType('array<int|string, string>', array_column($array, 'column', 'key'));

/** @var non-empty-array<int, array<string, string>> $array */
// Note: Array may still be empty!
assertType('array<int, string>', array_column($array, 'column'));

/** @var array{} $array */
assertType('array{}', array_column($array, 'column'));
assertType('array{}', array_column($array, 'column', 'key'));
}

function testConstantArrays(array $array): void
{
/** @var array<int, array{column: string, key: string}> $array */
assertType('array<int, string>', array_column($array, 'column'));
assertType('array<string, string>', array_column($array, 'column', 'key'));

/** @var array<int, array{column: string, key: string}> $array */
assertType('array{}', array_column($array, 'foo'));
assertType('array{}', array_column($array, 'foo', 'key'));

/** @var array{array{column: string, key: 'bar'}} $array */
assertType("array{string}", array_column($array, 'column'));
assertType("array{bar: string}", array_column($array, 'column', 'key'));

/** @var array{array{column: string, key: string}} $array */
assertType("non-empty-array<string, string>", array_column($array, 'column', 'key'));

/** @var array<int, array{column?: 'foo', key?: 'bar'}> $array */
assertType("array<int, 'foo'>", array_column($array, 'column'));
assertType("array<'bar'|int, 'foo'>", array_column($array, 'column', 'key'));

/** @var array<int, array{column1: string, column2: bool}> $array */
assertType('array<int, bool|string>', array_column($array, mt_rand(0, 1) === 0 ? 'column1' : 'column2'));

/** @var non-empty-array<int, array{column: string, key: string}> $array */
assertType('non-empty-array<int, string>', array_column($array, 'column'));
assertType('non-empty-array<string, string>', array_column($array, 'column', 'key'));
}

function testImprecise(array $array): void {
// These cases aren't handled precisely and will return non-constant arrays.

/** @var array{array{column?: 'foo', key: 'bar'}} $array */
assertType("array<int, 'foo'>", array_column($array, 'column'));
assertType("array<'bar', 'foo'>", array_column($array, 'column', 'key'));

/** @var array{array{column: 'foo', key?: 'bar'}} $array */
assertType("non-empty-array<'bar'|int, 'foo'>", array_column($array, 'column', 'key'));

/** @var array{array{column: 'foo', key: 'bar'}}|array<int, array<string, string>> $array */
assertType('array<int, string>', array_column($array, 'column'));
assertType('array<int|string, string>', array_column($array, 'column', 'key'));

/** @var array{0?: array{column: 'foo', key: 'bar'}} $array */
assertType("array<int, 'foo'>", array_column($array, 'column'));
assertType("array<'bar', 'foo'>", array_column($array, 'column', 'key'));
}

function testObjects(array $array): void {
/** @var array<int, DOMElement> $array */
assertType('array<int, string>', array_column($array, 'nodeName'));
assertType('array<string, string>', array_column($array, 'nodeName', 'tagName'));
assertType('array<int, mixed>', array_column($array, 'foo'));
assertType('array<string, mixed>', array_column($array, 'foo', 'tagName'));
assertType('array<string>', array_column($array, 'nodeName', 'foo'));

/** @var non-empty-array<int, DOMElement> $array */
assertType('non-empty-array<int, string>', array_column($array, 'nodeName'));
assertType('non-empty-array<string, string>', array_column($array, 'nodeName', 'tagName'));
assertType('array<int, mixed>', array_column($array, 'foo'));
assertType('array<string, mixed>', array_column($array, 'foo', 'tagName'));
assertType('non-empty-array<string>', array_column($array, 'nodeName', 'foo'));

/** @var array{DOMElement} $array */
assertType('array{string}', array_column($array, 'nodeName'));
assertType('non-empty-array<string, string>', array_column($array, 'nodeName', 'tagName'));
assertType('array<int, mixed>', array_column($array, 'foo'));
assertType('array<string, mixed>', array_column($array, 'foo', 'tagName'));
assertType('non-empty-array<int|string, string>', array_column($array, 'nodeName', 'foo'));
}

0 comments on commit e8509b7

Please sign in to comment.