From 79e18f3603750bb74df7a1ad528a95e9e3236df5 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 22 Jun 2022 22:07:45 +0900 Subject: [PATCH] Type a ranged value for filter_var(FILTER_VALIDATE_INT) --- .../FilterVarDynamicReturnTypeExtension.php | 126 ++++++++++++++---- .../filter-var-returns-non-empty-string.php | 47 ++++++- 2 files changed, 142 insertions(+), 31 deletions(-) diff --git a/src/Type/Php/FilterVarDynamicReturnTypeExtension.php b/src/Type/Php/FilterVarDynamicReturnTypeExtension.php index 4da682e9d5..80601e6858 100644 --- a/src/Type/Php/FilterVarDynamicReturnTypeExtension.php +++ b/src/Type/Php/FilterVarDynamicReturnTypeExtension.php @@ -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; @@ -41,6 +43,9 @@ class FilterVarDynamicReturnTypeExtension implements DynamicFunctionReturnTypeEx /** @var array|null */ private ?array $filterTypeMap = null; + /** @var array>|null */ + private ?array $filterTypeOptions = null; + public function __construct(private ReflectionProvider $reflectionProvider) { $this->flagsString = new ConstantStringType('flags'); @@ -91,6 +96,24 @@ private function getFilterTypeMap(): array return $this->filterTypeMap; } + /** + * @return array> + */ + 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'], + ]; + + return $this->filterTypeOptions; + } + private function getConstant(string $constantName): int { $constant = $this->reflectionProvider->getConstant(new Node\Name($constantName), null); @@ -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); } @@ -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 $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 + */ + 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 diff --git a/tests/PHPStan/Analyser/data/filter-var-returns-non-empty-string.php b/tests/PHPStan/Analyser/data/filter-var-returns-non-empty-string.php index bcdf815694..85b62e38b3 100644 --- a/tests/PHPStan/Analyser/data/filter-var-returns-non-empty-string.php +++ b/tests/PHPStan/Analyser/data/filter-var-returns-non-empty-string.php @@ -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); @@ -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|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|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);