diff --git a/conf/config.neon b/conf/config.neon index a9699fee478..a6d61abe13f 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -893,6 +893,11 @@ services: tags: - phpstan.broker.dynamicFunctionReturnTypeExtension + - + class: PHPStan\Type\Php\ArrayColumnFunctionReturnTypeExtension + tags: + - phpstan.broker.dynamicFunctionReturnTypeExtension + - class: PHPStan\Type\Php\ArrayCombineFunctionReturnTypeExtension tags: diff --git a/src/TrinaryLogic.php b/src/TrinaryLogic.php index 92e98d9381b..8776d145190 100644 --- a/src/TrinaryLogic.php +++ b/src/TrinaryLogic.php @@ -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); @@ -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)); } diff --git a/src/Type/Php/ArrayColumnFunctionReturnTypeExtension.php b/src/Type/Php/ArrayColumnFunctionReturnTypeExtension.php new file mode 100644 index 00000000000..566487965e5 --- /dev/null +++ b/src/Type/Php/ArrayColumnFunctionReturnTypeExtension.php @@ -0,0 +1,173 @@ +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); + } + +} diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index 3325578f771..1a0ccb53e3d 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -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'); } /** diff --git a/tests/PHPStan/Analyser/data/array-column.php b/tests/PHPStan/Analyser/data/array-column.php new file mode 100644 index 00000000000..7c64e1cdd99 --- /dev/null +++ b/tests/PHPStan/Analyser/data/array-column.php @@ -0,0 +1,92 @@ +> $array */ + assertType('array', array_column($array, 'column')); + assertType('array', array_column($array, 'column', 'key')); + + /** @var non-empty-array> $array */ + // Note: Array may still be empty! + assertType('array', 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 $array */ + assertType('array', array_column($array, 'column')); + assertType('array', array_column($array, 'column', 'key')); + + /** @var array $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", array_column($array, 'column', 'key')); + + /** @var array $array */ + assertType("array", array_column($array, 'column')); + assertType("array<'bar'|int, 'foo'>", array_column($array, 'column', 'key')); + + /** @var array $array */ + assertType('array', array_column($array, mt_rand(0, 1) === 0 ? 'column1' : 'column2')); + + /** @var non-empty-array $array */ + assertType('non-empty-array', array_column($array, 'column')); + assertType('non-empty-array', 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", 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> $array */ + assertType('array', array_column($array, 'column')); + assertType('array', array_column($array, 'column', 'key')); + + /** @var array{0?: array{column: 'foo', key: 'bar'}} $array */ + assertType("array", array_column($array, 'column')); + assertType("array<'bar', 'foo'>", array_column($array, 'column', 'key')); +} + +function testObjects(array $array): void { + /** @var array $array */ + assertType('array', array_column($array, 'nodeValue')); + assertType('array', array_column($array, 'nodeValue', 'tagName')); + assertType('array', array_column($array, 'foo')); + assertType('array', array_column($array, 'foo', 'tagName')); + assertType('array', array_column($array, 'nodeValue', 'foo')); + + /** @var non-empty-array $array */ + assertType('non-empty-array', array_column($array, 'nodeValue')); + assertType('non-empty-array', array_column($array, 'nodeValue', 'tagName')); + assertType('array', array_column($array, 'foo')); + assertType('array', array_column($array, 'foo', 'tagName')); + assertType('non-empty-array', array_column($array, 'nodeValue', 'foo')); + + /** @var array{DOMElement} $array */ + assertType('array{string}', array_column($array, 'nodeValue')); + assertType('non-empty-array', array_column($array, 'nodeValue', 'tagName')); + assertType('array', array_column($array, 'foo')); + assertType('array', array_column($array, 'foo', 'tagName')); + assertType('non-empty-array', array_column($array, 'nodeValue', 'foo')); +}