Skip to content

Commit

Permalink
Type a ranged value for filter_var(FILTER_VALIDATE_INT)
Browse files Browse the repository at this point in the history
  • Loading branch information
zonuexe committed Jun 22, 2022
1 parent a6163fd commit 79e18f3
Show file tree
Hide file tree
Showing 2 changed files with 142 additions and 31 deletions.
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'],
];

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

0 comments on commit 79e18f3

Please sign in to comment.