Skip to content

Commit 657f1a1

Browse files
committed
Resolve CodeIgniter casts to PHPStan types
1 parent ff71701 commit 657f1a1

2 files changed

Lines changed: 184 additions & 0 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) 2023 CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\PHPStan\Database\Schema;
15+
16+
use CodeIgniter\HTTP\URI;
17+
use CodeIgniter\I18n\Time;
18+
use PHPStan\Type\Accessory\AccessoryArrayListType;
19+
use PHPStan\Type\ArrayType;
20+
use PHPStan\Type\BooleanType;
21+
use PHPStan\Type\FloatType;
22+
use PHPStan\Type\IntegerType;
23+
use PHPStan\Type\MixedType;
24+
use PHPStan\Type\ObjectType;
25+
use PHPStan\Type\StringType;
26+
use PHPStan\Type\Type;
27+
use PHPStan\Type\TypeCombinator;
28+
use stdClass;
29+
use UnitEnum;
30+
31+
/**
32+
* Maps a CodeIgniter `$casts` entry to the PHPStan type its handler reads back, or null when the
33+
* cast name is not a framework built-in.
34+
*/
35+
final class CastTypeResolver
36+
{
37+
public function resolve(string $cast): ?Type
38+
{
39+
$nullable = str_starts_with($cast, '?');
40+
41+
if ($nullable) {
42+
$cast = substr($cast, 1);
43+
}
44+
45+
if ($cast === 'json-array') {
46+
$cast = 'json[array]';
47+
}
48+
49+
$params = [];
50+
51+
if (preg_match('/\A(.+)\[(.+)\]\z/', $cast, $matches) === 1) {
52+
$cast = $matches[1];
53+
$params = array_map(trim(...), explode(',', $matches[2]));
54+
}
55+
56+
$type = $this->mapCast($cast, $params);
57+
58+
if ($type === null) {
59+
return null;
60+
}
61+
62+
return $nullable ? TypeCombinator::addNull($type) : $type;
63+
}
64+
65+
/**
66+
* @param list<string> $params
67+
*/
68+
private function mapCast(string $cast, array $params): ?Type
69+
{
70+
return match ($cast) {
71+
'int', 'integer' => new IntegerType(),
72+
'float', 'double' => new FloatType(),
73+
'bool', 'boolean', 'int-bool' => new BooleanType(),
74+
'string' => new StringType(),
75+
'object' => new ObjectType(stdClass::class),
76+
'array' => new ArrayType(new MixedType(), new MixedType()),
77+
'csv' => TypeCombinator::intersect(new ArrayType(new IntegerType(), new StringType()), new AccessoryArrayListType()),
78+
'json' => in_array('array', $params, true) ? new ArrayType(new MixedType(), new MixedType()) : new ObjectType(stdClass::class),
79+
'datetime', 'timestamp' => new ObjectType(Time::class),
80+
'uri' => new ObjectType(URI::class),
81+
'enum' => $this->enumType($params),
82+
default => null,
83+
};
84+
}
85+
86+
/**
87+
* @param list<string> $params
88+
*/
89+
private function enumType(array $params): Type
90+
{
91+
if (isset($params[0])) {
92+
return new ObjectType($params[0]);
93+
}
94+
95+
return new ObjectType(UnitEnum::class);
96+
}
97+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) 2023 CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\PHPStan\Tests\Database\Schema;
15+
16+
use CodeIgniter\PHPStan\Database\Schema\CastTypeResolver;
17+
use PHPStan\Type\VerbosityLevel;
18+
use PHPUnit\Framework\Attributes\DataProvider;
19+
use PHPUnit\Framework\Attributes\Group;
20+
use PHPUnit\Framework\TestCase;
21+
22+
/**
23+
* @internal
24+
*/
25+
#[Group('unit')]
26+
final class CastTypeResolverTest extends TestCase
27+
{
28+
#[DataProvider('provideResolvesBuiltInCastCases')]
29+
public function testResolvesBuiltInCast(string $cast, string $expected): void
30+
{
31+
$type = (new CastTypeResolver())->resolve($cast);
32+
33+
self::assertNotNull($type);
34+
self::assertSame($expected, $type->describe(VerbosityLevel::precise()));
35+
}
36+
37+
/**
38+
* @return iterable<string, array{string, string}>
39+
*/
40+
public static function provideResolvesBuiltInCastCases(): iterable
41+
{
42+
yield 'int' => ['int', 'int'];
43+
44+
yield 'integer' => ['integer', 'int'];
45+
46+
yield 'float' => ['float', 'float'];
47+
48+
yield 'double' => ['double', 'float'];
49+
50+
yield 'bool' => ['bool', 'bool'];
51+
52+
yield 'int-bool' => ['int-bool', 'bool'];
53+
54+
yield 'string' => ['string', 'string'];
55+
56+
yield 'object' => ['object', 'stdClass'];
57+
58+
yield 'array' => ['array', 'array'];
59+
60+
yield 'csv' => ['csv', 'list<string>'];
61+
62+
yield 'json (object)' => ['json', 'stdClass'];
63+
64+
yield 'json[array]' => ['json[array]', 'array'];
65+
66+
yield 'json-array' => ['json-array', 'array'];
67+
68+
yield 'datetime' => ['datetime', 'CodeIgniter\I18n\Time'];
69+
70+
yield 'timestamp' => ['timestamp', 'CodeIgniter\I18n\Time'];
71+
72+
yield 'uri' => ['uri', 'CodeIgniter\HTTP\URI'];
73+
74+
yield 'enum with class' => ['enum[CodeIgniter\Test\TestLogger]', 'CodeIgniter\Test\TestLogger'];
75+
76+
yield 'enum without class' => ['enum', 'UnitEnum'];
77+
78+
yield 'nullable int' => ['?int', 'int|null'];
79+
80+
yield 'nullable datetime' => ['?datetime', 'CodeIgniter\I18n\Time|null'];
81+
}
82+
83+
public function testReturnsNullForUnknownCast(): void
84+
{
85+
self::assertNull((new CastTypeResolver())->resolve('mycustomhandler'));
86+
}
87+
}

0 commit comments

Comments
 (0)