Skip to content

Commit

Permalink
Narrow type of json_decode
Browse files Browse the repository at this point in the history
  • Loading branch information
TomasVotruba committed May 4, 2022
1 parent 8ad35b3 commit 231990a
Show file tree
Hide file tree
Showing 7 changed files with 184 additions and 9 deletions.
1 change: 1 addition & 0 deletions build/composer-require-checker.json
Expand Up @@ -12,6 +12,7 @@
"Clue\\React\\Block\\await", "Hoa\\File\\Read"
],
"php-core-extensions" : [
"json",
"Core",
"date",
"pcre",
Expand Down
75 changes: 66 additions & 9 deletions src/Type/Php/JsonThrowOnErrorDynamicReturnTypeExtension.php
Expand Up @@ -10,10 +10,16 @@
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Type\BitwiseFlagHelper;
use PHPStan\Type\Constant\ConstantBooleanType;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\ConstantScalarType;
use PHPStan\Type\ConstantTypeHelper;
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use function in_array;
use stdClass;
use function is_bool;
use function json_decode;

class JsonThrowOnErrorDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension
{
Expand All @@ -35,14 +41,11 @@ public function isFunctionSupported(
FunctionReflection $functionReflection,
): bool
{
return $this->reflectionProvider->hasConstant(new FullyQualified('JSON_THROW_ON_ERROR'), null) && in_array(
$functionReflection->getName(),
[
'json_encode',
'json_decode',
],
true,
);
if ($functionReflection->getName() === 'json_decode') {
return true;
}

return $this->reflectionProvider->hasConstant(new FullyQualified('JSON_THROW_ON_ERROR'), null) && $functionReflection->getName() === 'json_encode';
}

public function getTypeFromFunctionCall(
Expand All @@ -53,6 +56,11 @@ public function getTypeFromFunctionCall(
{
$argumentPosition = $this->argumentPositions[$functionReflection->getName()];
$defaultReturnType = ParametersAcceptorSelector::selectSingle($functionReflection->getVariants())->getReturnType();

if ($functionReflection->getName() === 'json_decode') {
$defaultReturnType = $this->narrowTypeForJsonDecode($functionCall, $scope, $defaultReturnType);
}

if (!isset($functionCall->getArgs()[$argumentPosition])) {
return $defaultReturnType;
}
Expand All @@ -65,4 +73,53 @@ public function getTypeFromFunctionCall(
return $defaultReturnType;
}

private function narrowTypeForJsonDecode(FuncCall $funcCall, Scope $scope, Type $fallbackType): Type
{
$args = $funcCall->getArgs();
$isArrayWithoutStdClass = $this->isForceArrayWithoutStdClass($funcCall, $scope);

$firstValueType = $scope->getType($args[0]->value);
if ($firstValueType instanceof ConstantStringType) {
return $this->resolveConstantStringType($firstValueType, $isArrayWithoutStdClass);
}

if ($isArrayWithoutStdClass) {
return TypeCombinator::remove($fallbackType, new ObjectType(stdClass::class));
}

return $fallbackType;
}

/**
* Is "json_decode(..., true)"?
*/
private function isForceArrayWithoutStdClass(FuncCall $funcCall, Scope $scope): bool
{
$args = $funcCall->getArgs();
if (!isset($args[1])) {
return false;
}

$secondArgType = $scope->getType($args[1]->value);
$secondArgValue = $secondArgType instanceof ConstantScalarType ? $secondArgType->getValue() : null;

if (is_bool($secondArgValue)) {
return $secondArgValue;
}

if ($secondArgValue !== null || !isset($args[3])) {
return false;
}

// depends on used constants, @see https://www.php.net/manual/en/json.constants.php#constant.json-object-as-array
return $this->bitwiseFlagAnalyser->bitwiseOrContainsConstant($args[3]->value, $scope, 'JSON_OBJECT_AS_ARRAY')->yes();
}

private function resolveConstantStringType(ConstantStringType $constantStringType, bool $isForceArray): Type
{
$decodedValue = json_decode($constantStringType->getValue(), $isForceArray);

return ConstantTypeHelper::getTypeFromValue($decodedValue);
}

}
5 changes: 5 additions & 0 deletions tests/PHPStan/Analyser/NodeScopeResolverTest.php
Expand Up @@ -17,6 +17,11 @@ public function dataFileAsserts(): iterable
require_once __DIR__ . '/data/implode.php';
yield from $this->gatherAssertTypes(__DIR__ . '/data/implode.php');

yield from $this->gatherAssertTypes(__DIR__ . '/data/json-decode/narrow_type.php');
yield from $this->gatherAssertTypes(__DIR__ . '/data/json-decode/narrow_type_with_force_array.php');
yield from $this->gatherAssertTypes(__DIR__ . '/data/json-decode/invalid_type.php');
yield from $this->gatherAssertTypes(__DIR__ . '/data/json-decode/json_object_as_array.php');

require_once __DIR__ . '/data/bug2574.php';

yield from $this->gatherAssertTypes(__DIR__ . '/data/bug2574.php');
Expand Down
17 changes: 17 additions & 0 deletions tests/PHPStan/Analyser/data/json-decode/invalid_type.php
@@ -0,0 +1,17 @@
<?php

namespace Analyser\JsonDecode;

use function PHPStan\Testing\assertType;

$value = json_decode('{"key"}');
assertType('null', $value);

$value = json_decode('{"key"}', true);
assertType('null', $value);

$value = json_decode('{"key"}', null);
assertType('null', $value);

$value = json_decode('{"key"}', false);
assertType('null', $value);
33 changes: 33 additions & 0 deletions tests/PHPStan/Analyser/data/json-decode/json_object_as_array.php
@@ -0,0 +1,33 @@
<?php

namespace Analyser\JsonDecode;

use function PHPStan\Testing\assertType;

// @see https://3v4l.org/YFlHF
function ($mixed) {
$value = json_decode($mixed, null, 512, JSON_OBJECT_AS_ARRAY);
assertType('mixed~stdClass', $value);
};

function ($mixed) {
$flagsAsVariable = JSON_OBJECT_AS_ARRAY;

$value = json_decode($mixed, null, 512, $flagsAsVariable);
assertType('mixed~stdClass', $value);
};

function ($mixed) {
$value = json_decode($mixed, null, 512, JSON_OBJECT_AS_ARRAY | JSON_BIGINT_AS_STRING);
assertType('mixed~stdClass', $value);
};

function ($mixed) {
$value = json_decode($mixed, null);
assertType('mixed', $value);
};

function ($mixed, $unknownFlags) {
$value = json_decode($mixed, null, 512, $unknownFlags);
assertType('mixed', $value);
};
34 changes: 34 additions & 0 deletions tests/PHPStan/Analyser/data/json-decode/narrow_type.php
@@ -0,0 +1,34 @@
<?php

namespace Analyser\JsonDecode;

use function PHPStan\Testing\assertType;

$value = json_decode('true');
assertType('true', $value);

$value = json_decode('1');
assertType('1', $value);

$value = json_decode('1.5');
assertType('1.5', $value);

$value = json_decode('false');
assertType('false', $value);

$value = json_decode('{}');
assertType('stdClass', $value);

$value = json_decode('[1, 2, 3]');
assertType('array{1, 2, 3}', $value);


function ($mixed) {
$value = json_decode($mixed);
assertType('mixed', $value);
};

function ($mixed) {
$value = json_decode($mixed, false);
assertType('mixed', $value);
};
@@ -0,0 +1,28 @@
<?php

namespace Analyser\JsonDecode;

use function PHPStan\Testing\assertType;

$value = json_decode('true', true);
assertType('true', $value);

$value = json_decode('1', true);
assertType('1', $value);

$value = json_decode('1.5', true);
assertType('1.5', $value);

$value = json_decode('false', true);
assertType('false', $value);

$value = json_decode('{}', true);
assertType('array{}', $value);

$value = json_decode('[1, 2, 3]', true);
assertType('array{1, 2, 3}', $value);

function ($mixed) {
$value = json_decode($mixed, true);
assertType('mixed~stdClass', $value);
};

0 comments on commit 231990a

Please sign in to comment.