Skip to content

Commit 1ae8c6a

Browse files
authored
Merge pull request #12 from cego/niza/do-not-ignore-exact-null-types-for-casts
Added check for nullable, even for casted values
2 parents 28f0362 + cd7cb4b commit 1ae8c6a

File tree

7 files changed

+127
-39
lines changed

7 files changed

+127
-39
lines changed

src/SpatieLaravelData/Rules/ValidTypeRule.php

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -115,22 +115,46 @@ private function compareTypes(Call $call, array $casts, Constructor $constructor
115115
*/
116116
private function checkType(Call $call, string $key, UnionType $actualType, array $casts, UnionType $expectedType): ?RuleError
117117
{
118-
// Ignore cases, where there exists a cast - since we cannot analyse them in dept
118+
// Casters cannot cast nullable values, so quick test for type error
119+
// is simply to check if the expected type accepts null values or not.
120+
if ($actualType->isNullable() && $expectedType->isNotNullable()) {
121+
return $this->buildError($call, $key, $expectedType, $actualType);
122+
}
123+
124+
// Otherwise, ignore cases where there exists a cast - since we cannot analyse them in dept.
119125
if ($this->expectedTypesMatchesExactlyCast($casts, $expectedType)) {
120126
return null;
121127
}
122128

129+
// Run full type inspection and return any errors found.
123130
if ( ! TypeSystem::isSubtypeOf($actualType, $expectedType)) {
124-
return RuleErrorBuilder::message(self::getErrorMessage($key, $call->target, $expectedType, $actualType))
125-
->line($call->method->line)
126-
->file($call->method->file)
127-
->tip('This is a custom CEGO rule, if you found a bug fix it in the cego/phpstan project')
128-
->build();
131+
return $this->buildError($call, $key, $expectedType, $actualType);
129132
}
130133

131134
return null;
132135
}
133136

137+
/**
138+
* Builds a RuleError instance
139+
*
140+
* @param Call $call
141+
* @param string $key
142+
* @param string $expectedType
143+
* @param string $actualType
144+
*
145+
* @throws ShouldNotHappenException
146+
*
147+
* @return RuleError
148+
*/
149+
private function buildError(Call $call, string $key, string $expectedType, string $actualType): RuleError
150+
{
151+
return RuleErrorBuilder::message(self::getErrorMessage($key, $call->target, $expectedType, $actualType))
152+
->line($call->method->line)
153+
->file($call->method->file)
154+
->tip('This is a custom CEGO rule, if you found a bug fix it in the cego/phpstan project')
155+
->build();
156+
}
157+
134158
/**
135159
* Returns the error message to give the developer on errors
136160
*

src/TypeSystem/IntersectionType.php

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,20 +77,37 @@ public function __toString(): string
7777
->implode('&');
7878
}
7979

80+
/**
81+
* Returns true if the type is considered to accept "null"
82+
*
83+
* @return bool
84+
*/
85+
public function isNullable(): bool
86+
{
87+
// An intersection type is mixed, if any of the types are considered nullable.
88+
foreach ($this->types as $type) {
89+
if ($type->isNull()) {
90+
return true;
91+
}
92+
}
93+
94+
return false;
95+
}
96+
8097
/**
8198
* Returns true if the type is considered to accept "mixed"
8299
*
83100
* @return bool
84101
*/
85102
public function isMixed(): bool
86103
{
87-
// An intersection type is mixed, if all the types are considered mixed.
104+
// An intersection type is mixed, if any of the types are considered mixed.
88105
foreach ($this->types as $type) {
89-
if ( ! $type->isMixed()) {
90-
return false;
106+
if ($type->isMixed()) {
107+
return true;
91108
}
92109
}
93110

94-
return true;
111+
return false;
95112
}
96113
}

src/TypeSystem/Type.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ public function isNotReal(): bool
3333
return in_array(strtolower($this->type), ['void', 'never'], true);
3434
}
3535

36+
/**
37+
* Returns true if the type is null
38+
*
39+
* @return bool
40+
*/
41+
public function isNull(): bool
42+
{
43+
return strtolower($this->type) === 'null';
44+
}
45+
3646
/**
3747
* Returns true if the type is of mixed
3848
*
@@ -136,7 +146,7 @@ public function toString(): string
136146
public function __toString(): string
137147
{
138148
if ($this->isClassOrInterface()) {
139-
return $this->type;
149+
return ltrim($this->type, '\\');
140150
}
141151

142152
// Handles when empty string == mixed

src/TypeSystem/UnionType.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,33 @@ public static function fromString(string $type): self
4242
);
4343
}
4444

45+
/**
46+
* Returns true if the type is nullable
47+
*
48+
* @return bool
49+
*/
50+
public function isNullable(): bool
51+
{
52+
// A union type is nullable, if just one of the types are nullable
53+
foreach ($this->intersectionTypes as $type) {
54+
if ($type->isNullable()) {
55+
return true;
56+
}
57+
}
58+
59+
return false;
60+
}
61+
62+
/**
63+
* Returns true if the type is not nullable
64+
*
65+
* @return bool
66+
*/
67+
public function isNotNullable(): bool
68+
{
69+
return ! $this->isNullable();
70+
}
71+
4572
public function isMixed(): bool
4673
{
4774
// A union type is mixed, if just one of the types are considered mixed

test/Samples/CastedSpatieLaravelData.php

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,33 +14,6 @@ public function __construct(
1414

1515
public function initDefault(): self
1616
{
17-
self::from(
18-
[
19-
'castedProperty' => 'hello world', // A cast exists, so we don't actually know if this is legal or not
20-
],
21-
[
22-
'castedProperty' => 123, // A cast exists, so we don't actually know if this is legal or not | Maybe the cast converts the int into a string :shrug:
23-
],
24-
);
25-
26-
self::from(
27-
[
28-
'castedProperty' => 'hello world', // A cast exists, so we don't actually know if this is legal or not
29-
],
30-
[
31-
'castedProperty' => 123, // A cast exists, so we don't actually know if this is legal or not | Maybe the cast converts the int into a string :shrug:
32-
],
33-
);
34-
35-
self::from(
36-
[
37-
'castedProperty' => 'hello world', // A cast exists, so we don't actually know if this is legal or not
38-
],
39-
[
40-
'castedProperty' => 123, // A cast exists, so we don't actually know if this is legal or not | Maybe the cast converts the int into a string :shrug:
41-
],
42-
);
43-
4417
return self::from(
4518
[
4619
'castedProperty' => 'hello world', // A cast exists, so we don't actually know if this is legal or not
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
namespace Test\Samples;
4+
5+
use Spatie\LaravelData\Data;
6+
use Test\Samples\Enum\BackedEnumExample;
7+
8+
class InvalidNullableCastedSpatieLaravelData extends Data
9+
{
10+
public function __construct(
11+
public readonly BackedEnumExample $castedProperty,
12+
) {
13+
}
14+
15+
public function initDefault(): self
16+
{
17+
self::from(
18+
[
19+
'castedProperty' => null, // We should know for certain that null is not accepted here
20+
],
21+
);
22+
}
23+
}

test/SpatieLaravelData/ValidTypeRuleTest.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
use PHPStan\Rules\Rule;
66
use PHPStan\Testing\RuleTestCase;
7+
use Test\Samples\Enum\BackedEnumExample;
78
use Test\Samples\InvalidScalarSpatieLaravelData;
89
use Test\Samples\InvalidComplexSpatieLaravelData;
910
use Cego\phpstan\SpatieLaravelData\Rules\ValidTypeRule;
11+
use Test\Samples\InvalidNullableCastedSpatieLaravelData;
1012
use Cego\phpstan\SpatieLaravelData\Collectors\CastCollector;
1113
use Cego\phpstan\SpatieLaravelData\Collectors\FromCollector;
1214
use Cego\phpstan\SpatieLaravelData\Collectors\ConstructorCollector;
@@ -64,7 +66,7 @@ public function it_returns_errors_for_invalid_complex_data(): void
6466
$this->expectError(20, 'nullableTypeStringProperty', InvalidComplexSpatieLaravelData::class, 'null|string', 'float'),
6567
$this->expectError(20, 'nullableMarkStringProperty', InvalidComplexSpatieLaravelData::class, 'null|string', 'array'),
6668
$this->expectError(20, 'nullableTypeStringProperty', InvalidComplexSpatieLaravelData::class, 'null|string', 'array'),
67-
$this->expectError(20, 'intersectionProperty', InvalidComplexSpatieLaravelData::class, '\Spatie\LaravelData\Casts\Cast&\Stringable', 'stdClass'),
69+
$this->expectError(20, 'intersectionProperty', InvalidComplexSpatieLaravelData::class, 'Spatie\LaravelData\Casts\Cast&Stringable', 'stdClass'),
6870
]);
6971
}
7072

@@ -78,6 +80,18 @@ public function it_ignores_potential_problems_for_objects_with_casts(): void
7880
], []);
7981
}
8082

83+
/** @test */
84+
public function it_does_not_ignore_null_check_for_casted_types(): void
85+
{
86+
$this->analyse([
87+
__DIR__ . '/../Samples/InvalidNullableCastedSpatieLaravelData.php',
88+
__DIR__ . '/../Samples/Casts/BackedEnumCast.php',
89+
__DIR__ . '/../Samples/Casts/UncastableSpatieDataCast.php',
90+
], [
91+
$this->expectError(17, 'castedProperty', InvalidNullableCastedSpatieLaravelData::class, BackedEnumExample::class, 'null'),
92+
]);
93+
}
94+
8195
/** @test */
8296
public function it_does_not_care_about_generics(): void
8397
{

0 commit comments

Comments
 (0)