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

Type a ranged value for filter_var(FILTER_VALIDATE_INT) #1443

Merged
merged 6 commits into from Jun 22, 2022
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
126 changes: 97 additions & 29 deletions src/Type/Php/FilterVarDynamicReturnTypeExtension.php
Expand Up @@ -15,16 +15,18 @@
use PHPStan\Type\Constant\ConstantBooleanType;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\ConstantScalarType;
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
use PHPStan\Type\ErrorType;
use PHPStan\Type\FloatType;
use PHPStan\Type\IntegerRangeType;
use PHPStan\Type\IntegerType;
use PHPStan\Type\IntersectionType;
use PHPStan\Type\MixedType;
use PHPStan\Type\NullType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use PHPStan\Type\UnionType;
use PHPStan\Type\TypeCombinator;
use function is_int;
use function sprintf;
use function strtolower;

Expand All @@ -41,6 +43,9 @@ class FilterVarDynamicReturnTypeExtension implements DynamicFunctionReturnTypeEx
/** @var array<int, Type>|null */
private ?array $filterTypeMap = null;

/** @var array<int, list<string>>|null */
private ?array $filterTypeOptions = null;

public function __construct(private ReflectionProvider $reflectionProvider)
{
$this->flagsString = new ConstantStringType('flags');
Expand Down Expand Up @@ -91,6 +96,24 @@ private function getFilterTypeMap(): array
return $this->filterTypeMap;
}

/**
* @return array<int, list<string>>
*/
private function getFilterTypeOptions(): array
{
if ($this->filterTypeOptions !== null) {
return $this->filterTypeOptions;
}

$this->filterTypeOptions = [
$this->getConstant('FILTER_VALIDATE_INT') => ['min_range', 'max_range'],
// PHPStan does not yet support FloatRangeType
// $this->getConstant('FILTER_VALIDATE_FLOAT') => ['min_range', 'max_range'],
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't plan to use it immediately, but it is possible to make FloatRangeType with -INF <= float <= INF.

];

return $this->filterTypeOptions;
}

private function getConstant(string $constantName): int
{
$constant = $this->reflectionProvider->getConstant(new Node\Name($constantName), null);
Expand Down Expand Up @@ -129,23 +152,37 @@ public function getTypeFromFunctionCall(
$flagsArg = $functionCall->getArgs()[2] ?? null;
$inputType = $scope->getType($functionCall->getArgs()[0]->value);
$exactType = $this->determineExactType($inputType, $filterValue);
if ($exactType !== null) {
$type = $exactType;
} else {
$type = $this->getFilterTypeMap()[$filterValue] ?? $mixedType;
$otherType = $this->getOtherType($flagsArg, $scope);
$type = $exactType ?? $this->getFilterTypeMap()[$filterValue] ?? $mixedType;

if ($inputType->isNonEmptyString()->yes()
&& $type instanceof StringType
&& !$this->canStringBeSanitized($filterValue, $flagsArg, $scope)) {
$type = new IntersectionType([$type, new AccessoryNonEmptyStringType()]);
}
$typeOptionNames = $this->getFilterTypeOptions()[$filterValue] ?? [];
$otherTypes = $this->getOtherTypes($flagsArg, $scope, $typeOptionNames);

if ($inputType->isNonEmptyString()->yes()
&& $type->isString()->yes()
&& !$this->canStringBeSanitized($filterValue, $flagsArg, $scope)) {
$type = TypeCombinator::intersect($type, new AccessoryNonEmptyStringType());
}

if (isset($otherTypes['range'])) {
if ($type instanceof ConstantScalarType) {
if ($otherTypes['range']->isSuperTypeOf($type)->no()) {
$type = $otherTypes['default'];
}

if ($otherType->isSuperTypeOf($type)->no()) {
$type = new UnionType([$type, $otherType]);
unset($otherTypes['default']);
} else {
$type = $otherTypes['range'];
}
}

if ($exactType !== null) {
unset($otherTypes['default']);
}

if (isset($otherTypes['default']) && $otherTypes['default']->isSuperTypeOf($type)->no()) {
$type = TypeCombinator::union($type, $otherTypes['default']);
}

if ($this->hasFlag($this->getConstant('FILTER_FORCE_ARRAY'), $flagsArg, $scope)) {
return new ArrayType(new MixedType(), $type);
}
Expand All @@ -168,43 +205,74 @@ private function determineExactType(Type $in, int $filterValue): ?Type
return null;
}

private function getOtherType(?Node\Arg $flagsArg, Scope $scope): Type
/**
* @param list<string> $typeOptionNames
* @return array{default: Type, range?: Type}
*/
private function getOtherTypes(?Node\Arg $flagsArg, Scope $scope, array $typeOptionNames): array
{
$falseType = new ConstantBooleanType(false);
if ($flagsArg === null) {
return $falseType;
return ['default' => $falseType];
}

$typeOptions = $this->getOptions($flagsArg, $scope, 'default', ...$typeOptionNames);
$defaultType = $typeOptions['default'] ?? null;
if ($defaultType === null) {
$defaultType = $this->hasFlag($this->getConstant('FILTER_NULL_ON_FAILURE'), $flagsArg, $scope)
? new NullType()
: $falseType;
}

$defaultType = $this->getDefault($flagsArg, $scope);
if ($defaultType !== null) {
return $defaultType;
$otherTypes = ['default' => $defaultType];
$range = [];
if (isset($typeOptions['min_range'])) {
if ($typeOptions['min_range'] instanceof ConstantScalarType) {
$range['min'] = $typeOptions['min_range']->getValue();
} elseif ($typeOptions['min_range'] instanceof IntegerRangeType) {
$range['min'] = $typeOptions['min_range']->getMin();
}
}
if (isset($typeOptions['max_range'])) {
if ($typeOptions['max_range'] instanceof ConstantScalarType) {
$range['max'] = $typeOptions['max_range']->getValue();
} elseif ($typeOptions['max_range'] instanceof IntegerRangeType) {
$range['max'] = $typeOptions['max_range']->getMax();
}
}

if ($this->hasFlag($this->getConstant('FILTER_NULL_ON_FAILURE'), $flagsArg, $scope)) {
return new NullType();
if (isset($range['min']) || isset($range['max'])) {
$min = isset($range['min']) && is_int($range['min']) ? $range['min'] : null;
$max = isset($range['max']) && is_int($range['max']) ? $range['max'] : null;
$otherTypes['range'] = IntegerRangeType::fromInterval($min, $max);
}

return $falseType;
return $otherTypes;
}

private function getDefault(Node\Arg $expression, Scope $scope): ?Type
/**
* @return array<string, ?Type>
*/
private function getOptions(Node\Arg $expression, Scope $scope, string ...$optionNames): array
{
$options = [];

$exprType = $scope->getType($expression->value);
if (!$exprType instanceof ConstantArrayType) {
return null;
return $options;
}

$optionsType = $exprType->getOffsetValueType(new ConstantStringType('options'));
if (!$optionsType instanceof ConstantArrayType) {
return null;
return $options;
}

$defaultType = $optionsType->getOffsetValueType(new ConstantStringType('default'));
if (!$defaultType instanceof ErrorType) {
return $defaultType;
foreach ($optionNames as $optionName) {
$type = $optionsType->getOffsetValueType(new ConstantStringType($optionName));
$options[$optionName] = $type instanceof ErrorType ? null : $type;
}

return null;
return $options;
}

private function hasFlag(int $flag, ?Node\Arg $expression, Scope $scope): bool
Expand Down
Expand Up @@ -6,8 +6,12 @@

class Foo
{
/** @param non-empty-string $str */
public function run(string $str): void
/**
* @param non-empty-string $str
* @param positive-int $positive_int
* @param negative-int $negative_int
*/
public function run(string $str, int $int, int $positive_int, int $negative_int): void
{
assertType('non-empty-string', $str);

Expand Down Expand Up @@ -50,6 +54,45 @@ public function run(string $str): void
$return = filter_var($str, FILTER_VALIDATE_INT);
assertType('int|false', $return);

$return = filter_var($str, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
assertType('int<1, max>|false', $return);

$return = filter_var($str, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1], 'flags' => FILTER_NULL_ON_FAILURE]);
assertType('int<1, max>|null', $return);

$return = filter_var($str, FILTER_VALIDATE_INT, ['options' => ['max_range' => 0]]);
assertType('int<min, 0>|false', $return);

$return = filter_var($str, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 9]]);
assertType('int<1, 9>|false', $return);

$return = filter_var(100, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 9]]);
assertType('false', $return);

$return = filter_var(100, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 1]]);
assertType('false', $return);

$return = filter_var(1, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 9]]);
assertType('1', $return);

$return = filter_var(1, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 1]]);
assertType('1', $return);

$return = filter_var(9, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 9]]);
assertType('9', $return);

$return = filter_var(1.0, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 9]]);
assertType('int<1, 9>|false', $return);

$return = filter_var($str, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => $positive_int]]);
assertType('int<1, max>|false', $return);

$return = filter_var($str, FILTER_VALIDATE_INT, ['options' => ['min_range' => $negative_int, 'max_range' => 0]]);
assertType('int<min, 0>|false', $return);

$return = filter_var($str, FILTER_VALIDATE_INT, ['options' => ['min_range' => $int, 'max_range' => $int]]);
assertType('int|false', $return);

$str2 = '';
$return = filter_var($str2, FILTER_DEFAULT);
assertType('string|false', $return);
Expand Down