From 930e7879d04e3b09de00c4391f8f90faeb35d28d Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Sun, 19 Jun 2022 23:45:05 +0900 Subject: [PATCH 1/6] Type a ranged value for filter_var(FILTER_VALIDATE_INT) --- .../FilterVarDynamicReturnTypeExtension.php | 125 ++++++++++++++---- .../filter-var-returns-non-empty-string.php | 41 +++++- 2 files changed, 137 insertions(+), 29 deletions(-) diff --git a/src/Type/Php/FilterVarDynamicReturnTypeExtension.php b/src/Type/Php/FilterVarDynamicReturnTypeExtension.php index 4da682e9d5..026fd8d986 100644 --- a/src/Type/Php/FilterVarDynamicReturnTypeExtension.php +++ b/src/Type/Php/FilterVarDynamicReturnTypeExtension.php @@ -15,9 +15,11 @@ 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; @@ -25,6 +27,7 @@ use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\UnionType; +use function is_int; use function sprintf; use function strtolower; @@ -41,6 +44,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 +97,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 +153,39 @@ 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 instanceof StringType + && !$this->canStringBeSanitized($filterValue, $flagsArg, $scope)) { + $type = new IntersectionType([$type, new AccessoryNonEmptyStringType()]); + } - if ($otherType->isSuperTypeOf($type)->no()) { - $type = new UnionType([$type, $otherType]); + if (isset($otherTypes['range'])) { + if ($type instanceof ConstantScalarType) { + if ($type->getValue() < $otherTypes['range']->getMin() + || $type->getValue() > $otherTypes['range']->getMax() + ) { + $type = $otherTypes['default']; + } + + unset($otherTypes['default']); + } else { + $type = $otherTypes['range']; } } + if ($exactType !== null) { + unset($otherTypes['default']); + } + + if (isset($otherTypes['default']) && $otherTypes['default']->isSuperTypeOf($type)->no()) { + $type = new UnionType([$type, $otherTypes['default']]); + } + if ($this->hasFlag($this->getConstant('FILTER_FORCE_ARRAY'), $flagsArg, $scope)) { return new ArrayType(new MixedType(), $type); } @@ -168,43 +208,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?: IntegerRangeType} + */ + private function getOtherTypes(?Node\Arg $flagsArg, Scope $scope, array $typeOptionNames): array { $falseType = new ConstantBooleanType(false); if ($flagsArg === null) { - return $falseType; + return ['default' => $falseType]; } - $defaultType = $this->getDefault($flagsArg, $scope); - if ($defaultType !== null) { - return $defaultType; + $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; } - if ($this->hasFlag($this->getConstant('FILTER_NULL_ON_FAILURE'), $flagsArg, $scope)) { - return new NullType(); + $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 (isset($range['min']) || isset($range['max'])) { + $min = is_int($range['min'] ?? null) ? $range['min'] : null; + $max = is_int($range['max'] ?? null) ? $range['max'] : null; + $otherTypes['range'] = new IntegerRangeType($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..f207212cc5 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,39 @@ 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(1, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 9]]); + 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); From 90a568f3e107bc252581095d65734e257bca0e4a Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Mon, 20 Jun 2022 00:12:02 +0900 Subject: [PATCH 2/6] Use isset() && is_int() instead of is_int($array[key] ?? null) --- src/Type/Php/FilterVarDynamicReturnTypeExtension.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Type/Php/FilterVarDynamicReturnTypeExtension.php b/src/Type/Php/FilterVarDynamicReturnTypeExtension.php index 026fd8d986..67e4d8411b 100644 --- a/src/Type/Php/FilterVarDynamicReturnTypeExtension.php +++ b/src/Type/Php/FilterVarDynamicReturnTypeExtension.php @@ -245,8 +245,8 @@ private function getOtherTypes(?Node\Arg $flagsArg, Scope $scope, array $typeOpt } if (isset($range['min']) || isset($range['max'])) { - $min = is_int($range['min'] ?? null) ? $range['min'] : null; - $max = is_int($range['max'] ?? null) ? $range['max'] : null; + $min = isset($range['min']) && is_int($range['min']) ? $range['min'] : null; + $max = isset($range['max']) && is_int($range['max']) ? $range['max'] : null; $otherTypes['range'] = new IntegerRangeType($min, $max); } From fec2ef66a70218522bf5954338b3aa84dd8b3a20 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Mon, 20 Jun 2022 16:56:37 +0900 Subject: [PATCH 3/6] Use TypeCombinator method instead of directly create instance objects --- src/Type/Php/FilterVarDynamicReturnTypeExtension.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Type/Php/FilterVarDynamicReturnTypeExtension.php b/src/Type/Php/FilterVarDynamicReturnTypeExtension.php index 67e4d8411b..de28c2ede0 100644 --- a/src/Type/Php/FilterVarDynamicReturnTypeExtension.php +++ b/src/Type/Php/FilterVarDynamicReturnTypeExtension.php @@ -21,12 +21,11 @@ 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; @@ -161,7 +160,7 @@ public function getTypeFromFunctionCall( if ($inputType->isNonEmptyString()->yes() && $type instanceof StringType && !$this->canStringBeSanitized($filterValue, $flagsArg, $scope)) { - $type = new IntersectionType([$type, new AccessoryNonEmptyStringType()]); + $type = TypeCombinator::intersect($type, new AccessoryNonEmptyStringType()); } if (isset($otherTypes['range'])) { @@ -183,7 +182,7 @@ public function getTypeFromFunctionCall( } if (isset($otherTypes['default']) && $otherTypes['default']->isSuperTypeOf($type)->no()) { - $type = new UnionType([$type, $otherTypes['default']]); + $type = TypeCombinator::union($type, $otherTypes['default']); } if ($this->hasFlag($this->getConstant('FILTER_FORCE_ARRAY'), $flagsArg, $scope)) { From e301f6dbaea2eeed2a740d862c37602b940387d8 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Mon, 20 Jun 2022 16:58:10 +0900 Subject: [PATCH 4/6] Use Type::isString() instead of instanceof StringType --- src/Type/Php/FilterVarDynamicReturnTypeExtension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/Php/FilterVarDynamicReturnTypeExtension.php b/src/Type/Php/FilterVarDynamicReturnTypeExtension.php index de28c2ede0..39acc27a75 100644 --- a/src/Type/Php/FilterVarDynamicReturnTypeExtension.php +++ b/src/Type/Php/FilterVarDynamicReturnTypeExtension.php @@ -158,7 +158,7 @@ public function getTypeFromFunctionCall( $otherTypes = $this->getOtherTypes($flagsArg, $scope, $typeOptionNames); if ($inputType->isNonEmptyString()->yes() - && $type instanceof StringType + && $type->isString()->yes() && !$this->canStringBeSanitized($filterValue, $flagsArg, $scope)) { $type = TypeCombinator::intersect($type, new AccessoryNonEmptyStringType()); } From 09ae19764dab721c0ff26ef34ee363d089928316 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Mon, 20 Jun 2022 16:58:42 +0900 Subject: [PATCH 5/6] Use IntegerRangeType::fromInterval() instead of new --- src/Type/Php/FilterVarDynamicReturnTypeExtension.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Type/Php/FilterVarDynamicReturnTypeExtension.php b/src/Type/Php/FilterVarDynamicReturnTypeExtension.php index 39acc27a75..80601e6858 100644 --- a/src/Type/Php/FilterVarDynamicReturnTypeExtension.php +++ b/src/Type/Php/FilterVarDynamicReturnTypeExtension.php @@ -165,9 +165,7 @@ public function getTypeFromFunctionCall( if (isset($otherTypes['range'])) { if ($type instanceof ConstantScalarType) { - if ($type->getValue() < $otherTypes['range']->getMin() - || $type->getValue() > $otherTypes['range']->getMax() - ) { + if ($otherTypes['range']->isSuperTypeOf($type)->no()) { $type = $otherTypes['default']; } @@ -209,7 +207,7 @@ private function determineExactType(Type $in, int $filterValue): ?Type /** * @param list $typeOptionNames - * @return array{default: Type, range?: IntegerRangeType} + * @return array{default: Type, range?: Type} */ private function getOtherTypes(?Node\Arg $flagsArg, Scope $scope, array $typeOptionNames): array { @@ -246,7 +244,7 @@ private function getOtherTypes(?Node\Arg $flagsArg, Scope $scope, array $typeOpt 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'] = new IntegerRangeType($min, $max); + $otherTypes['range'] = IntegerRangeType::fromInterval($min, $max); } return $otherTypes; From 409707da7f898189bb072d5f385cda0ee0afc2ab Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 22 Jun 2022 20:20:42 +0900 Subject: [PATCH 6/6] Add test cases to check matched ConstantScalarType --- .../Analyser/data/filter-var-returns-non-empty-string.php | 6 ++++++ 1 file changed, 6 insertions(+) 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 f207212cc5..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 @@ -69,9 +69,15 @@ public function run(string $str, int $int, int $positive_int, int $negative_int) $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);