Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add FilterVarArrayDynamicReturnTypeExtension #2257

Merged
merged 7 commits into from
Apr 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions conf/config.neon
Original file line number Diff line number Diff line change
Expand Up @@ -1466,6 +1466,11 @@ services:
tags:
- phpstan.broker.dynamicFunctionReturnTypeExtension

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

-
class: PHPStan\Type\Php\GetCalledClassDynamicReturnTypeExtension
tags:
Expand Down
204 changes: 204 additions & 0 deletions src/Type/Php/FilterVarArrayDynamicReturnTypeExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
<?php declare(strict_types = 1);

namespace PHPStan\Type\Php;

use PhpParser\Node;
use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\FunctionReflection;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Type\Accessory\AccessoryArrayListType;
use PHPStan\Type\ArrayType;
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
use PHPStan\Type\MixedType;
use PHPStan\Type\NeverType;
use PHPStan\Type\NullType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use function array_combine;
use function array_fill_keys;
use function array_map;
use function count;
use function in_array;
use function strtolower;

class FilterVarArrayDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension
{

public function __construct(private FilterFunctionReturnTypeHelper $filterFunctionReturnTypeHelper, private ReflectionProvider $reflectionProvider)
{
}

public function isFunctionSupported(FunctionReflection $functionReflection): bool
{
return in_array(strtolower($functionReflection->getName()), ['filter_var_array', 'filter_input_array'], true);
}

public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type
{
if (count($functionCall->getArgs()) < 2) {
return null;
}

$functionName = strtolower($functionReflection->getName());
$inputArgType = $scope->getType($functionCall->getArgs()[0]->value);

$inputArrayType = $inputArgType;
$inputConstantArrayType = null;
if ($functionName === 'filter_var_array') {
if ($inputArgType->isArray()->no()) {
return new NeverType();
}

$inputConstantArrayType = $inputArgType->getConstantArrays()[0] ?? null;
} elseif ($functionName === 'filter_input_array') {
$supportedTypes = TypeCombinator::union(
$this->reflectionProvider->getConstant(new Node\Name('INPUT_GET'), null)->getValueType(),
$this->reflectionProvider->getConstant(new Node\Name('INPUT_POST'), null)->getValueType(),
$this->reflectionProvider->getConstant(new Node\Name('INPUT_COOKIE'), null)->getValueType(),
$this->reflectionProvider->getConstant(new Node\Name('INPUT_SERVER'), null)->getValueType(),
$this->reflectionProvider->getConstant(new Node\Name('INPUT_ENV'), null)->getValueType(),
);

if (!$inputArgType->isInteger()->yes() || $supportedTypes->isSuperTypeOf($inputArgType)->no()) {
return null;
}

// Pragmatical solution since global expressions are not passed through the scope for performance reasons
// See https://github.com/phpstan/phpstan-src/pull/2012 for details
$inputArrayType = new ArrayType(new StringType(), new MixedType());
}

$filterArgType = $scope->getType($functionCall->getArgs()[1]->value);
$filterConstantArrayType = $filterArgType->getConstantArrays()[0] ?? null;
$addEmptyType = isset($functionCall->getArgs()[2]) ? $scope->getType($functionCall->getArgs()[2]->value) : null;
$addEmpty = $addEmptyType === null || $addEmptyType->isTrue()->yes();

$valueTypesBuilder = ConstantArrayTypeBuilder::createEmpty();
$inputTypesMap = [];
$optionalKeys = [];

if ($filterArgType instanceof ConstantIntegerType) {
if ($inputConstantArrayType === null) {
$isList = $inputArrayType->isList()->yes();
$inputArrayType = $inputArrayType->getArrays()[0] ?? null;
$valueType = $this->filterFunctionReturnTypeHelper->getType(
$inputArrayType === null ? new MixedType() : $inputArrayType->getItemType(),
$filterArgType,
null,
);
$arrayType = new ArrayType(
$inputArrayType !== null ? $inputArrayType->getKeyType() : new MixedType(),
$valueType,
);

return $isList ? AccessoryArrayListType::intersectWith($arrayType) : $arrayType;
}

// Override $add_empty option
$addEmpty = false;

$keysType = $inputConstantArrayType;
$inputKeysList = array_map(static fn ($type) => $type->getValue(), $inputConstantArrayType->getKeyTypes());
$filterTypesMap = array_fill_keys($inputKeysList, $filterArgType);
$inputTypesMap = array_combine($inputKeysList, $inputConstantArrayType->getValueTypes());
$optionalKeys = [];
foreach ($inputConstantArrayType->getOptionalKeys() as $index) {
if (!isset($inputKeysList[$index])) {
continue;
}

$optionalKeys[] = $inputKeysList[$index];
}
} elseif ($filterConstantArrayType === null) {
if ($inputConstantArrayType === null) {
$isList = $inputArrayType->isList()->yes();
$inputArrayType = $inputArrayType->getArrays()[0] ?? null;
$valueType = $this->filterFunctionReturnTypeHelper->getType($inputArrayType ?? new MixedType(), $filterArgType, null);

$arrayType = new ArrayType(
$inputArrayType !== null ? $inputArrayType->getKeyType() : new MixedType(),
$addEmpty ? TypeCombinator::addNull($valueType) : $valueType,
);

return $isList ? AccessoryArrayListType::intersectWith($arrayType) : $arrayType;
}

return null;
} else {
$keysType = $filterConstantArrayType;
$filterKeyTypes = $filterConstantArrayType->getKeyTypes();
$filterKeysList = array_map(static fn ($type) => $type->getValue(), $filterKeyTypes);
$filterTypesMap = array_combine($filterKeysList, $keysType->getValueTypes());

if ($inputConstantArrayType !== null) {
$inputKeysList = array_map(static fn ($type) => $type->getValue(), $inputConstantArrayType->getKeyTypes());
$inputTypesMap = array_combine($inputKeysList, $inputConstantArrayType->getValueTypes());

$optionalKeys = [];
foreach ($inputConstantArrayType->getOptionalKeys() as $index) {
if (!isset($inputKeysList[$index])) {
continue;
}

$optionalKeys[] = $inputKeysList[$index];
}
} else {
$optionalKeys = $filterKeysList;
$inputTypesMap = array_fill_keys($optionalKeys, $inputArrayType->getArrays()[0]->getItemType());
}
}

foreach ($keysType->getKeyTypes() as $keyType) {
$optional = false;
$key = $keyType->getValue();
$inputType = $inputTypesMap[$key] ?? null;
if ($inputType === null) {
if ($addEmpty) {
$valueTypesBuilder->setOffsetValueType($keyType, new NullType());
}

continue;
}

[$filterType, $flagsType] = $this->fetchFilter($filterTypesMap[$key] ?? new MixedType());
$valueType = $this->filterFunctionReturnTypeHelper->getType($inputType, $filterType, $flagsType);

if (in_array($key, $optionalKeys, true)) {
if ($addEmpty) {
$valueType = TypeCombinator::addNull($valueType);
} else {
$optional = true;
}
}

$valueTypesBuilder->setOffsetValueType($keyType, $valueType, $optional);
}

return $valueTypesBuilder->getArray();
}

/** @return array{?Type, ?Type} */
public function fetchFilter(Type $type): array
{
$constantType = $type->getConstantArrays()[0] ?? null;

if ($constantType === null) {
return [$type, null];
}

$filterType = null;
foreach ($constantType->getKeyTypes() as $keyType) {
if ($keyType->getValue() === 'filter') {
$filterType = $constantType->getOffsetValueType($keyType)->getConstantScalarTypes()[0] ?? null;
break;
}
}

return [$filterType, $constantType];
}

}
3 changes: 3 additions & 0 deletions tests/PHPStan/Analyser/NodeScopeResolverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,10 @@ public function dataFileAsserts(): iterable
} else {
yield from $this->gatherAssertTypes(__DIR__ . '/data/filter-input-php7.php');
}

yield from $this->gatherAssertTypes(__DIR__ . '/data/filter-input-array.php');
yield from $this->gatherAssertTypes(__DIR__ . '/data/filter-var.php');
yield from $this->gatherAssertTypes(__DIR__ . '/data/filter-var-array.php');

if (PHP_VERSION_ID >= 80100) {
yield from $this->gatherAssertTypes(__DIR__ . '/data/enums.php');
Expand Down
79 changes: 79 additions & 0 deletions tests/PHPStan/Analyser/data/filter-input-array.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php declare(strict_types=1);

namespace FilterVarArray;

use function PHPStan\Testing\assertType;

class FilterInput
{
function superGlobalVariables(): void
{
$filter = [
'filter' => FILTER_VALIDATE_INT,
'flag' => FILTER_REQUIRE_SCALAR,
'options' => ['min_range' => 1],
];

// filter array with add_empty=default
assertType('array{int: int|false|null, positive_int: int<1, max>|false|null}', filter_input_array(INPUT_GET, [
'int' => FILTER_VALIDATE_INT,
'positive_int' => $filter,
]));

// filter array with add_empty=true
assertType('array{int: int|false|null, positive_int: int<1, max>|false|null}', filter_input_array(INPUT_GET, [
'int' => FILTER_VALIDATE_INT,
'positive_int' => $filter,
], true));

// filter array with add_empty=false
assertType('array{int?: int|false, positive_int?: int<1, max>|false}', filter_input_array(INPUT_GET, [
'int' => FILTER_VALIDATE_INT,
'positive_int' => $filter,
], false));

// filter flag with add_empty=default
assertType('array<string, int|false>', filter_input_array(INPUT_GET, FILTER_VALIDATE_INT));
// filter flag with add_empty=true
assertType('array<string, int|false>', filter_input_array(INPUT_GET, FILTER_VALIDATE_INT, true));
// filter flag with add_empty=false
assertType('array<string, int|false>', filter_input_array(INPUT_GET, FILTER_VALIDATE_INT, false));
}

/**
* @param array<mixed> $arrayFilter
* @param FILTER_VALIDATE_* $intFilter
*/
function dynamicFilter(array $input, array $arrayFilter, int $intFilter): void
{
// filter array with add_empty=default
assertType('array<string, mixed>', filter_input_array(INPUT_GET, $arrayFilter));
// filter array with add_empty=true
assertType('array<string, mixed>', filter_input_array(INPUT_GET, $arrayFilter, true));
// filter array with add_empty=false
assertType('array<string, mixed>', filter_input_array(INPUT_GET, $arrayFilter, false));

// filter flag with add_empty=default
assertType('array<string, mixed>', filter_input_array(INPUT_GET, $intFilter));
// filter flag with add_empty=true
assertType('array<string, mixed>', filter_input_array(INPUT_GET, $intFilter, true));
// filter flag with add_empty=false
assertType('array<string, mixed>', filter_input_array(INPUT_GET, $intFilter, false));
}

/**
* @param INPUT_GET|INPUT_POST $union
*/
public function dynamicInputType($union, mixed $mixed): void
{
$filter = [
'filter' => FILTER_VALIDATE_INT,
'flag' => FILTER_REQUIRE_SCALAR,
'options' => ['min_range' => 1],
];

assertType('array{foo: int<1, max>|false|null}', filter_input_array($union, ['foo' => $filter]));
assertType('array|false|null', filter_input_array($mixed, ['foo' => $filter]));
}

}