Skip to content

Commit cabdc76

Browse files
committed
Make datetime and json casts nullable on nullable columns
1 parent 8ffb2e2 commit cabdc76

7 files changed

Lines changed: 83 additions & 10 deletions

File tree

src/Database/Schema/CastFieldTypeResolver.php

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,21 @@ public function __construct(
3333
/**
3434
* @param array<string, string> $castHandlers
3535
*/
36-
public function resolve(string $cast, array $castHandlers): Type
36+
public function resolve(string $cast, array $castHandlers, bool $columnIsNonNullable = false): Type
3737
{
38-
return $this->castTypeResolver->resolve($cast) ?? $this->resolveCustomHandler($cast, $castHandlers);
38+
$type = $this->castTypeResolver->resolve($cast);
39+
40+
if ($type === null) {
41+
return $this->resolveCustomHandler($cast, $castHandlers);
42+
}
43+
44+
// A null column value survives null-preserving casts (datetime/json) as null, so the property
45+
// is nullable unless the backing column is known to be non-null.
46+
if (! $columnIsNonNullable && $this->castTypeResolver->preservesNull($cast)) {
47+
return TypeCombinator::addNull($type);
48+
}
49+
50+
return $type;
3951
}
4052

4153
/**

src/Database/Schema/CastTypeResolver.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,23 @@ public function resolve(string $cast): ?Type
6262
return $nullable ? TypeCombinator::addNull($type) : $type;
6363
}
6464

65+
/**
66+
* Whether the cast's handler returns null for a null input, so a null column value survives the
67+
* cast as null. The other built-in handlers coerce null (e.g. `(int) null` is `0`) or throw.
68+
*/
69+
public function preservesNull(string $cast): bool
70+
{
71+
if (str_starts_with($cast, '?') || $cast === 'json-array') {
72+
return true;
73+
}
74+
75+
if (preg_match('/\A(.+)\[.+\]\z/', $cast, $matches) === 1) {
76+
$cast = $matches[1];
77+
}
78+
79+
return in_array($cast, ['datetime', 'json'], true);
80+
}
81+
6582
/**
6683
* @param list<string> $params
6784
*/

src/Reflection/EntityPropertiesClassReflectionExtension.php

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
use PHPStan\Type\MixedType;
2727
use PHPStan\Type\ObjectType;
2828
use PHPStan\Type\Type;
29+
use PHPStan\Type\TypeCombinator;
2930

3031
/**
3132
* Types virtual properties on `CodeIgniter\Entity\Entity` subclasses, layering `$dates` and `$casts`
@@ -61,12 +62,19 @@ private function resolveType(ClassReflection $classReflection, string $propertyN
6162

6263
// `__get()` mutates date fields to Time before any cast. Only claim real columns so that
6364
// the framework's default `$dates` don't fabricate Time properties on unrelated entities.
65+
// A null column value mutates to null, so a nullable date column is `Time|null`.
6466
if ($schema !== null && in_array($column, $this->readStringList($classReflection, 'dates'), true)) {
65-
return new ObjectType(Time::class);
67+
$time = new ObjectType(Time::class);
68+
69+
return $schema->nullable ? TypeCombinator::addNull($time) : $time;
6670
}
6771

6872
if (isset($casts[$column])) {
69-
return $this->castFieldTypeResolver->resolve($casts[$column], $this->readStringMap($classReflection, 'castHandlers'));
73+
return $this->castFieldTypeResolver->resolve(
74+
$casts[$column],
75+
$this->readStringMap($classReflection, 'castHandlers'),
76+
$schema !== null && ! $schema->nullable,
77+
);
7078
}
7179

7280
if ($schema !== null && ! $this->hasGetter($classReflection, $column)) {

src/Type/ModelFetchedReturnTypeHelper.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ private function columnsToFields(Table $table, array $casts, array $castHandlers
293293
private function fieldType(string $name, ?Column $column, array $casts, array $castHandlers): Type
294294
{
295295
if (isset($casts[$name])) {
296-
return $this->castFieldTypeResolver->resolve($casts[$name], $castHandlers);
296+
return $this->castFieldTypeResolver->resolve($casts[$name], $castHandlers, $column !== null && ! $column->nullable);
297297
}
298298

299299
return $column !== null ? $this->columnTypeResolver->resolve($column) : new MixedType();

tests/Database/Schema/CastTypeResolverTest.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,40 @@ public function testReturnsNullForUnknownCast(): void
8484
{
8585
self::assertNull((new CastTypeResolver())->resolve('mycustomhandler'));
8686
}
87+
88+
#[DataProvider('providePreservesNullCases')]
89+
public function testPreservesNull(string $cast, bool $expected): void
90+
{
91+
self::assertSame($expected, (new CastTypeResolver())->preservesNull($cast));
92+
}
93+
94+
/**
95+
* @return iterable<string, array{string, bool}>
96+
*/
97+
public static function providePreservesNullCases(): iterable
98+
{
99+
yield 'datetime' => ['datetime', true];
100+
101+
yield 'json' => ['json', true];
102+
103+
yield 'json[array]' => ['json[array]', true];
104+
105+
yield 'json-array' => ['json-array', true];
106+
107+
yield 'nullable int' => ['?int', true];
108+
109+
yield 'int' => ['int', false];
110+
111+
yield 'string' => ['string', false];
112+
113+
yield 'csv' => ['csv', false];
114+
115+
yield 'object' => ['object', false];
116+
117+
yield 'timestamp' => ['timestamp', false];
118+
119+
yield 'uri' => ['uri', false];
120+
121+
yield 'enum with class' => ['enum[CodeIgniter\Test\TestLogger]', false];
122+
}
87123
}

tests/data/type-inference/entity-cast-properties.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@
2424
assertType('bool', $entity->is_active);
2525
assertType('float|null', $entity->rating);
2626
assertType('string', $entity->name);
27-
assertType('stdClass', $entity->options);
28-
assertType('array', $entity->tags);
27+
assertType('stdClass|null', $entity->options);
28+
assertType('array|null', $entity->tags);
2929
assertType('list<string>', $entity->roles);
30-
assertType('CodeIgniter\I18n\Time', $entity->published);
30+
assertType('CodeIgniter\I18n\Time|null', $entity->published);
3131
assertType('CodeIgniter\PHPStan\Tests\Fixtures\Entity\Money', $entity->balance);
3232
assertType('CodeIgniter\PHPStan\Tests\Fixtures\Entity\Money|null', $entity->discount);
3333

tests/data/type-inference/entity-column-properties.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
assertType('int', $comment->identifier);
2424
assertType('string|null', $comment->body);
2525
assertType('int', $comment->votes);
26-
assertType('stdClass', $comment->payload);
27-
assertType('CodeIgniter\I18n\Time', $comment->created_at);
26+
assertType('stdClass|null', $comment->payload);
27+
assertType('CodeIgniter\I18n\Time|null', $comment->created_at);
2828

2929
assertType('mixed', $comment->nonexistent);

0 commit comments

Comments
 (0)