Skip to content

Commit f8fd310

Browse files
authored
Add FilterVarArrayDynamicReturnTypeExtension
1 parent e80bd9e commit f8fd310

File tree

5 files changed

+585
-0
lines changed

5 files changed

+585
-0
lines changed

conf/config.neon

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1466,6 +1466,11 @@ services:
14661466
tags:
14671467
- phpstan.broker.dynamicFunctionReturnTypeExtension
14681468

1469+
-
1470+
class: PHPStan\Type\Php\FilterVarArrayDynamicReturnTypeExtension
1471+
tags:
1472+
- phpstan.broker.dynamicFunctionReturnTypeExtension
1473+
14691474
-
14701475
class: PHPStan\Type\Php\GetCalledClassDynamicReturnTypeExtension
14711476
tags:
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Php;
4+
5+
use PhpParser\Node;
6+
use PhpParser\Node\Expr\FuncCall;
7+
use PHPStan\Analyser\Scope;
8+
use PHPStan\Reflection\FunctionReflection;
9+
use PHPStan\Reflection\ReflectionProvider;
10+
use PHPStan\Type\Accessory\AccessoryArrayListType;
11+
use PHPStan\Type\ArrayType;
12+
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
13+
use PHPStan\Type\Constant\ConstantIntegerType;
14+
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
15+
use PHPStan\Type\MixedType;
16+
use PHPStan\Type\NeverType;
17+
use PHPStan\Type\NullType;
18+
use PHPStan\Type\StringType;
19+
use PHPStan\Type\Type;
20+
use PHPStan\Type\TypeCombinator;
21+
use function array_combine;
22+
use function array_fill_keys;
23+
use function array_map;
24+
use function count;
25+
use function in_array;
26+
use function strtolower;
27+
28+
class FilterVarArrayDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension
29+
{
30+
31+
public function __construct(private FilterFunctionReturnTypeHelper $filterFunctionReturnTypeHelper, private ReflectionProvider $reflectionProvider)
32+
{
33+
}
34+
35+
public function isFunctionSupported(FunctionReflection $functionReflection): bool
36+
{
37+
return in_array(strtolower($functionReflection->getName()), ['filter_var_array', 'filter_input_array'], true);
38+
}
39+
40+
public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type
41+
{
42+
if (count($functionCall->getArgs()) < 2) {
43+
return null;
44+
}
45+
46+
$functionName = strtolower($functionReflection->getName());
47+
$inputArgType = $scope->getType($functionCall->getArgs()[0]->value);
48+
49+
$inputArrayType = $inputArgType;
50+
$inputConstantArrayType = null;
51+
if ($functionName === 'filter_var_array') {
52+
if ($inputArgType->isArray()->no()) {
53+
return new NeverType();
54+
}
55+
56+
$inputConstantArrayType = $inputArgType->getConstantArrays()[0] ?? null;
57+
} elseif ($functionName === 'filter_input_array') {
58+
$supportedTypes = TypeCombinator::union(
59+
$this->reflectionProvider->getConstant(new Node\Name('INPUT_GET'), null)->getValueType(),
60+
$this->reflectionProvider->getConstant(new Node\Name('INPUT_POST'), null)->getValueType(),
61+
$this->reflectionProvider->getConstant(new Node\Name('INPUT_COOKIE'), null)->getValueType(),
62+
$this->reflectionProvider->getConstant(new Node\Name('INPUT_SERVER'), null)->getValueType(),
63+
$this->reflectionProvider->getConstant(new Node\Name('INPUT_ENV'), null)->getValueType(),
64+
);
65+
66+
if (!$inputArgType->isInteger()->yes() || $supportedTypes->isSuperTypeOf($inputArgType)->no()) {
67+
return null;
68+
}
69+
70+
// Pragmatical solution since global expressions are not passed through the scope for performance reasons
71+
// See https://github.com/phpstan/phpstan-src/pull/2012 for details
72+
$inputArrayType = new ArrayType(new StringType(), new MixedType());
73+
}
74+
75+
$filterArgType = $scope->getType($functionCall->getArgs()[1]->value);
76+
$filterConstantArrayType = $filterArgType->getConstantArrays()[0] ?? null;
77+
$addEmptyType = isset($functionCall->getArgs()[2]) ? $scope->getType($functionCall->getArgs()[2]->value) : null;
78+
$addEmpty = $addEmptyType === null || $addEmptyType->isTrue()->yes();
79+
80+
$valueTypesBuilder = ConstantArrayTypeBuilder::createEmpty();
81+
$inputTypesMap = [];
82+
$optionalKeys = [];
83+
84+
if ($filterArgType instanceof ConstantIntegerType) {
85+
if ($inputConstantArrayType === null) {
86+
$isList = $inputArrayType->isList()->yes();
87+
$inputArrayType = $inputArrayType->getArrays()[0] ?? null;
88+
$valueType = $this->filterFunctionReturnTypeHelper->getType(
89+
$inputArrayType === null ? new MixedType() : $inputArrayType->getItemType(),
90+
$filterArgType,
91+
null,
92+
);
93+
$arrayType = new ArrayType(
94+
$inputArrayType !== null ? $inputArrayType->getKeyType() : new MixedType(),
95+
$valueType,
96+
);
97+
98+
return $isList ? AccessoryArrayListType::intersectWith($arrayType) : $arrayType;
99+
}
100+
101+
// Override $add_empty option
102+
$addEmpty = false;
103+
104+
$keysType = $inputConstantArrayType;
105+
$inputKeysList = array_map(static fn ($type) => $type->getValue(), $inputConstantArrayType->getKeyTypes());
106+
$filterTypesMap = array_fill_keys($inputKeysList, $filterArgType);
107+
$inputTypesMap = array_combine($inputKeysList, $inputConstantArrayType->getValueTypes());
108+
$optionalKeys = [];
109+
foreach ($inputConstantArrayType->getOptionalKeys() as $index) {
110+
if (!isset($inputKeysList[$index])) {
111+
continue;
112+
}
113+
114+
$optionalKeys[] = $inputKeysList[$index];
115+
}
116+
} elseif ($filterConstantArrayType === null) {
117+
if ($inputConstantArrayType === null) {
118+
$isList = $inputArrayType->isList()->yes();
119+
$inputArrayType = $inputArrayType->getArrays()[0] ?? null;
120+
$valueType = $this->filterFunctionReturnTypeHelper->getType($inputArrayType ?? new MixedType(), $filterArgType, null);
121+
122+
$arrayType = new ArrayType(
123+
$inputArrayType !== null ? $inputArrayType->getKeyType() : new MixedType(),
124+
$addEmpty ? TypeCombinator::addNull($valueType) : $valueType,
125+
);
126+
127+
return $isList ? AccessoryArrayListType::intersectWith($arrayType) : $arrayType;
128+
}
129+
130+
return null;
131+
} else {
132+
$keysType = $filterConstantArrayType;
133+
$filterKeyTypes = $filterConstantArrayType->getKeyTypes();
134+
$filterKeysList = array_map(static fn ($type) => $type->getValue(), $filterKeyTypes);
135+
$filterTypesMap = array_combine($filterKeysList, $keysType->getValueTypes());
136+
137+
if ($inputConstantArrayType !== null) {
138+
$inputKeysList = array_map(static fn ($type) => $type->getValue(), $inputConstantArrayType->getKeyTypes());
139+
$inputTypesMap = array_combine($inputKeysList, $inputConstantArrayType->getValueTypes());
140+
141+
$optionalKeys = [];
142+
foreach ($inputConstantArrayType->getOptionalKeys() as $index) {
143+
if (!isset($inputKeysList[$index])) {
144+
continue;
145+
}
146+
147+
$optionalKeys[] = $inputKeysList[$index];
148+
}
149+
} else {
150+
$optionalKeys = $filterKeysList;
151+
$inputTypesMap = array_fill_keys($optionalKeys, $inputArrayType->getArrays()[0]->getItemType());
152+
}
153+
}
154+
155+
foreach ($keysType->getKeyTypes() as $keyType) {
156+
$optional = false;
157+
$key = $keyType->getValue();
158+
$inputType = $inputTypesMap[$key] ?? null;
159+
if ($inputType === null) {
160+
if ($addEmpty) {
161+
$valueTypesBuilder->setOffsetValueType($keyType, new NullType());
162+
}
163+
164+
continue;
165+
}
166+
167+
[$filterType, $flagsType] = $this->fetchFilter($filterTypesMap[$key] ?? new MixedType());
168+
$valueType = $this->filterFunctionReturnTypeHelper->getType($inputType, $filterType, $flagsType);
169+
170+
if (in_array($key, $optionalKeys, true)) {
171+
if ($addEmpty) {
172+
$valueType = TypeCombinator::addNull($valueType);
173+
} else {
174+
$optional = true;
175+
}
176+
}
177+
178+
$valueTypesBuilder->setOffsetValueType($keyType, $valueType, $optional);
179+
}
180+
181+
return $valueTypesBuilder->getArray();
182+
}
183+
184+
/** @return array{?Type, ?Type} */
185+
public function fetchFilter(Type $type): array
186+
{
187+
$constantType = $type->getConstantArrays()[0] ?? null;
188+
189+
if ($constantType === null) {
190+
return [$type, null];
191+
}
192+
193+
$filterType = null;
194+
foreach ($constantType->getKeyTypes() as $keyType) {
195+
if ($keyType->getValue() === 'filter') {
196+
$filterType = $constantType->getOffsetValueType($keyType)->getConstantScalarTypes()[0] ?? null;
197+
break;
198+
}
199+
}
200+
201+
return [$filterType, $constantType];
202+
}
203+
204+
}

tests/PHPStan/Analyser/NodeScopeResolverTest.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,7 +630,10 @@ public function dataFileAsserts(): iterable
630630
} else {
631631
yield from $this->gatherAssertTypes(__DIR__ . '/data/filter-input-php7.php');
632632
}
633+
634+
yield from $this->gatherAssertTypes(__DIR__ . '/data/filter-input-array.php');
633635
yield from $this->gatherAssertTypes(__DIR__ . '/data/filter-var.php');
636+
yield from $this->gatherAssertTypes(__DIR__ . '/data/filter-var-array.php');
634637

635638
if (PHP_VERSION_ID >= 80100) {
636639
yield from $this->gatherAssertTypes(__DIR__ . '/data/enums.php');
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace FilterVarArray;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
class FilterInput
8+
{
9+
function superGlobalVariables(): void
10+
{
11+
$filter = [
12+
'filter' => FILTER_VALIDATE_INT,
13+
'flag' => FILTER_REQUIRE_SCALAR,
14+
'options' => ['min_range' => 1],
15+
];
16+
17+
// filter array with add_empty=default
18+
assertType('array{int: int|false|null, positive_int: int<1, max>|false|null}', filter_input_array(INPUT_GET, [
19+
'int' => FILTER_VALIDATE_INT,
20+
'positive_int' => $filter,
21+
]));
22+
23+
// filter array with add_empty=true
24+
assertType('array{int: int|false|null, positive_int: int<1, max>|false|null}', filter_input_array(INPUT_GET, [
25+
'int' => FILTER_VALIDATE_INT,
26+
'positive_int' => $filter,
27+
], true));
28+
29+
// filter array with add_empty=false
30+
assertType('array{int?: int|false, positive_int?: int<1, max>|false}', filter_input_array(INPUT_GET, [
31+
'int' => FILTER_VALIDATE_INT,
32+
'positive_int' => $filter,
33+
], false));
34+
35+
// filter flag with add_empty=default
36+
assertType('array<string, int|false>', filter_input_array(INPUT_GET, FILTER_VALIDATE_INT));
37+
// filter flag with add_empty=true
38+
assertType('array<string, int|false>', filter_input_array(INPUT_GET, FILTER_VALIDATE_INT, true));
39+
// filter flag with add_empty=false
40+
assertType('array<string, int|false>', filter_input_array(INPUT_GET, FILTER_VALIDATE_INT, false));
41+
}
42+
43+
/**
44+
* @param array<mixed> $arrayFilter
45+
* @param FILTER_VALIDATE_* $intFilter
46+
*/
47+
function dynamicFilter(array $input, array $arrayFilter, int $intFilter): void
48+
{
49+
// filter array with add_empty=default
50+
assertType('array<string, mixed>', filter_input_array(INPUT_GET, $arrayFilter));
51+
// filter array with add_empty=true
52+
assertType('array<string, mixed>', filter_input_array(INPUT_GET, $arrayFilter, true));
53+
// filter array with add_empty=false
54+
assertType('array<string, mixed>', filter_input_array(INPUT_GET, $arrayFilter, false));
55+
56+
// filter flag with add_empty=default
57+
assertType('array<string, mixed>', filter_input_array(INPUT_GET, $intFilter));
58+
// filter flag with add_empty=true
59+
assertType('array<string, mixed>', filter_input_array(INPUT_GET, $intFilter, true));
60+
// filter flag with add_empty=false
61+
assertType('array<string, mixed>', filter_input_array(INPUT_GET, $intFilter, false));
62+
}
63+
64+
/**
65+
* @param INPUT_GET|INPUT_POST $union
66+
*/
67+
public function dynamicInputType($union, mixed $mixed): void
68+
{
69+
$filter = [
70+
'filter' => FILTER_VALIDATE_INT,
71+
'flag' => FILTER_REQUIRE_SCALAR,
72+
'options' => ['min_range' => 1],
73+
];
74+
75+
assertType('array{foo: int<1, max>|false|null}', filter_input_array($union, ['foo' => $filter]));
76+
assertType('array|false|null', filter_input_array($mixed, ['foo' => $filter]));
77+
}
78+
79+
}

0 commit comments

Comments
 (0)