Skip to content

Commit 231990a

Browse files
authored
Narrow type of json_decode
1 parent 8ad35b3 commit 231990a

7 files changed

+184
-9
lines changed

Diff for: build/composer-require-checker.json

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"Clue\\React\\Block\\await", "Hoa\\File\\Read"
1313
],
1414
"php-core-extensions" : [
15+
"json",
1516
"Core",
1617
"date",
1718
"pcre",

Diff for: src/Type/Php/JsonThrowOnErrorDynamicReturnTypeExtension.php

+66-9
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,16 @@
1010
use PHPStan\Reflection\ReflectionProvider;
1111
use PHPStan\Type\BitwiseFlagHelper;
1212
use PHPStan\Type\Constant\ConstantBooleanType;
13+
use PHPStan\Type\Constant\ConstantStringType;
14+
use PHPStan\Type\ConstantScalarType;
15+
use PHPStan\Type\ConstantTypeHelper;
1316
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
17+
use PHPStan\Type\ObjectType;
1418
use PHPStan\Type\Type;
1519
use PHPStan\Type\TypeCombinator;
16-
use function in_array;
20+
use stdClass;
21+
use function is_bool;
22+
use function json_decode;
1723

1824
class JsonThrowOnErrorDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension
1925
{
@@ -35,14 +41,11 @@ public function isFunctionSupported(
3541
FunctionReflection $functionReflection,
3642
): bool
3743
{
38-
return $this->reflectionProvider->hasConstant(new FullyQualified('JSON_THROW_ON_ERROR'), null) && in_array(
39-
$functionReflection->getName(),
40-
[
41-
'json_encode',
42-
'json_decode',
43-
],
44-
true,
45-
);
44+
if ($functionReflection->getName() === 'json_decode') {
45+
return true;
46+
}
47+
48+
return $this->reflectionProvider->hasConstant(new FullyQualified('JSON_THROW_ON_ERROR'), null) && $functionReflection->getName() === 'json_encode';
4649
}
4750

4851
public function getTypeFromFunctionCall(
@@ -53,6 +56,11 @@ public function getTypeFromFunctionCall(
5356
{
5457
$argumentPosition = $this->argumentPositions[$functionReflection->getName()];
5558
$defaultReturnType = ParametersAcceptorSelector::selectSingle($functionReflection->getVariants())->getReturnType();
59+
60+
if ($functionReflection->getName() === 'json_decode') {
61+
$defaultReturnType = $this->narrowTypeForJsonDecode($functionCall, $scope, $defaultReturnType);
62+
}
63+
5664
if (!isset($functionCall->getArgs()[$argumentPosition])) {
5765
return $defaultReturnType;
5866
}
@@ -65,4 +73,53 @@ public function getTypeFromFunctionCall(
6573
return $defaultReturnType;
6674
}
6775

76+
private function narrowTypeForJsonDecode(FuncCall $funcCall, Scope $scope, Type $fallbackType): Type
77+
{
78+
$args = $funcCall->getArgs();
79+
$isArrayWithoutStdClass = $this->isForceArrayWithoutStdClass($funcCall, $scope);
80+
81+
$firstValueType = $scope->getType($args[0]->value);
82+
if ($firstValueType instanceof ConstantStringType) {
83+
return $this->resolveConstantStringType($firstValueType, $isArrayWithoutStdClass);
84+
}
85+
86+
if ($isArrayWithoutStdClass) {
87+
return TypeCombinator::remove($fallbackType, new ObjectType(stdClass::class));
88+
}
89+
90+
return $fallbackType;
91+
}
92+
93+
/**
94+
* Is "json_decode(..., true)"?
95+
*/
96+
private function isForceArrayWithoutStdClass(FuncCall $funcCall, Scope $scope): bool
97+
{
98+
$args = $funcCall->getArgs();
99+
if (!isset($args[1])) {
100+
return false;
101+
}
102+
103+
$secondArgType = $scope->getType($args[1]->value);
104+
$secondArgValue = $secondArgType instanceof ConstantScalarType ? $secondArgType->getValue() : null;
105+
106+
if (is_bool($secondArgValue)) {
107+
return $secondArgValue;
108+
}
109+
110+
if ($secondArgValue !== null || !isset($args[3])) {
111+
return false;
112+
}
113+
114+
// depends on used constants, @see https://www.php.net/manual/en/json.constants.php#constant.json-object-as-array
115+
return $this->bitwiseFlagAnalyser->bitwiseOrContainsConstant($args[3]->value, $scope, 'JSON_OBJECT_AS_ARRAY')->yes();
116+
}
117+
118+
private function resolveConstantStringType(ConstantStringType $constantStringType, bool $isForceArray): Type
119+
{
120+
$decodedValue = json_decode($constantStringType->getValue(), $isForceArray);
121+
122+
return ConstantTypeHelper::getTypeFromValue($decodedValue);
123+
}
124+
68125
}

Diff for: tests/PHPStan/Analyser/NodeScopeResolverTest.php

+5
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ public function dataFileAsserts(): iterable
1717
require_once __DIR__ . '/data/implode.php';
1818
yield from $this->gatherAssertTypes(__DIR__ . '/data/implode.php');
1919

20+
yield from $this->gatherAssertTypes(__DIR__ . '/data/json-decode/narrow_type.php');
21+
yield from $this->gatherAssertTypes(__DIR__ . '/data/json-decode/narrow_type_with_force_array.php');
22+
yield from $this->gatherAssertTypes(__DIR__ . '/data/json-decode/invalid_type.php');
23+
yield from $this->gatherAssertTypes(__DIR__ . '/data/json-decode/json_object_as_array.php');
24+
2025
require_once __DIR__ . '/data/bug2574.php';
2126

2227
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug2574.php');
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace Analyser\JsonDecode;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
$value = json_decode('{"key"}');
8+
assertType('null', $value);
9+
10+
$value = json_decode('{"key"}', true);
11+
assertType('null', $value);
12+
13+
$value = json_decode('{"key"}', null);
14+
assertType('null', $value);
15+
16+
$value = json_decode('{"key"}', false);
17+
assertType('null', $value);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace Analyser\JsonDecode;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
// @see https://3v4l.org/YFlHF
8+
function ($mixed) {
9+
$value = json_decode($mixed, null, 512, JSON_OBJECT_AS_ARRAY);
10+
assertType('mixed~stdClass', $value);
11+
};
12+
13+
function ($mixed) {
14+
$flagsAsVariable = JSON_OBJECT_AS_ARRAY;
15+
16+
$value = json_decode($mixed, null, 512, $flagsAsVariable);
17+
assertType('mixed~stdClass', $value);
18+
};
19+
20+
function ($mixed) {
21+
$value = json_decode($mixed, null, 512, JSON_OBJECT_AS_ARRAY | JSON_BIGINT_AS_STRING);
22+
assertType('mixed~stdClass', $value);
23+
};
24+
25+
function ($mixed) {
26+
$value = json_decode($mixed, null);
27+
assertType('mixed', $value);
28+
};
29+
30+
function ($mixed, $unknownFlags) {
31+
$value = json_decode($mixed, null, 512, $unknownFlags);
32+
assertType('mixed', $value);
33+
};
+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace Analyser\JsonDecode;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
$value = json_decode('true');
8+
assertType('true', $value);
9+
10+
$value = json_decode('1');
11+
assertType('1', $value);
12+
13+
$value = json_decode('1.5');
14+
assertType('1.5', $value);
15+
16+
$value = json_decode('false');
17+
assertType('false', $value);
18+
19+
$value = json_decode('{}');
20+
assertType('stdClass', $value);
21+
22+
$value = json_decode('[1, 2, 3]');
23+
assertType('array{1, 2, 3}', $value);
24+
25+
26+
function ($mixed) {
27+
$value = json_decode($mixed);
28+
assertType('mixed', $value);
29+
};
30+
31+
function ($mixed) {
32+
$value = json_decode($mixed, false);
33+
assertType('mixed', $value);
34+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
namespace Analyser\JsonDecode;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
$value = json_decode('true', true);
8+
assertType('true', $value);
9+
10+
$value = json_decode('1', true);
11+
assertType('1', $value);
12+
13+
$value = json_decode('1.5', true);
14+
assertType('1.5', $value);
15+
16+
$value = json_decode('false', true);
17+
assertType('false', $value);
18+
19+
$value = json_decode('{}', true);
20+
assertType('array{}', $value);
21+
22+
$value = json_decode('[1, 2, 3]', true);
23+
assertType('array{1, 2, 3}', $value);
24+
25+
function ($mixed) {
26+
$value = json_decode($mixed, true);
27+
assertType('mixed~stdClass', $value);
28+
};

0 commit comments

Comments
 (0)