Skip to content

Commit bc7e4cf

Browse files
LivijntcampbPPU
andauthored
feat: Add support for MorphTo union types (#114)
* feat: add support for nullable relations - Detect nullable return types in relationship methods - Mark nullable relations as optional (?) in TypeScript output - Handle union types containing null (e.g., BelongsTo|Listing|null) - Handle single nullable types (e.g., ?BelongsTo) * fix: use explicit | null for nullable relations instead of optional ? - Changed nullable relations to use 'Type | null' instead of 'Type?' - More explicit and follows TypeScript best practices - Optional relations (via config) still use '?' syntax * test: add tests for nullable relations - Add tests for WriteRelationship nullable handling - Add tests for ModelInspector nullable detection - Make isReturnTypeNullable public for testability - Test nullable union types (e.g., BelongsTo|Listing|null) - Test nullable named types (e.g., ?BelongsTo) - Test non-nullable relations - Test nullable plural relations * refactor: remove unnecessary tests and keep method protected - Remove tests for isReturnTypeNullable (implementation detail) - Keep isReturnTypeNullable protected (not public API) - Keep tests for WriteRelationship (public behavior) - All 116 tests passing * wip * perform assertion --------- Co-authored-by: tanner <tcamp022@gmail.com>
1 parent 9bff319 commit bc7e4cf

6 files changed

Lines changed: 313 additions & 16 deletions

File tree

src/Actions/WriteRelationship.php

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace FumeApp\ModelTyper\Actions;
44

55
use FumeApp\ModelTyper\Traits\ClassBaseName;
6+
use Illuminate\Database\Eloquent\Relations\MorphTo;
67
use Illuminate\Support\Facades\Config;
78
use Illuminate\Support\Str;
89

@@ -13,22 +14,33 @@ class WriteRelationship
1314
/**
1415
* Write the relationship to the output.
1516
*
16-
* @param array{name: string, type: string, related:string} $relation
17+
* @param array{name: string, type: string, related:string, nullable?: bool} $relation
1718
* @return array{type: string, name: string}|string
1819
*/
1920
public function __invoke(array $relation, string $indent = '', bool $jsonOutput = false, bool $optionalRelation = false, bool $plurals = false): array|string
2021
{
2122
$case = Config::get('modeltyper.case.relations', 'snake');
2223
$name = app(MatchCase::class)($case, $relation['name']);
2324

24-
$relatedModel = $this->getClassName($relation['related']);
25+
$isNullable = $relation['nullable'] ?? false;
2526
$optional = $optionalRelation ? '?' : '';
2627

27-
$relationType = match ($relation['type']) {
28-
'BelongsToMany', 'HasMany', 'HasManyThrough', 'MorphToMany', 'MorphMany', 'MorphedByMany' => $plurals === true ? Str::plural($relatedModel) : (Str::singular($relatedModel) . '[]'),
29-
'BelongsTo', 'HasOne', 'HasOneThrough', 'MorphOne', 'MorphTo' => Str::singular($relatedModel),
30-
default => $relatedModel,
31-
};
28+
// For MorphTo relations with union types, use the related string directly
29+
if ($relation['type'] === 'MorphTo' && str_contains($relation['related'], '|')) {
30+
$relationType = $relation['related'];
31+
} else {
32+
$relatedModel = $this->getClassName($relation['related']);
33+
34+
$relationType = match ($relation['type']) {
35+
'BelongsToMany', 'HasMany', 'HasManyThrough', 'MorphToMany', 'MorphMany', 'MorphedByMany' => $plurals === true ? Str::plural($relatedModel) : (Str::singular($relatedModel).'[]'),
36+
'BelongsTo', 'HasOne', 'HasOneThrough', 'MorphOne', 'MorphTo' => Str::singular($relatedModel),
37+
default => $relatedModel,
38+
};
39+
}
40+
41+
if ($isNullable) {
42+
$relationType .= ' | null';
43+
}
3244

3345
if (in_array($relation['type'], Config::get('modeltyper.custom_relationships.singular', []))) {
3446
$relationType = Str::singular($relation['type']);
@@ -45,6 +57,6 @@ public function __invoke(array $relation, string $indent = '', bool $jsonOutput
4557
];
4658
}
4759

48-
return "{$indent} {$name}{$optional}: {$relationType}" . PHP_EOL;
60+
return "{$indent} {$name}{$optional}: {$relationType}".PHP_EOL;
4961
}
5062
}

src/Overrides/ModelInspector.php

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,14 @@
44

55
use Illuminate\Contracts\Foundation\Application;
66
use Illuminate\Database\Eloquent\ModelInspector as EloquentModelInspector;
7+
use Illuminate\Database\Eloquent\Relations\MorphTo;
78
use Illuminate\Support\Arr;
89
use Illuminate\Support\Facades\Config;
910
use Illuminate\Support\Str;
11+
use ReflectionMethod;
12+
use ReflectionNamedType;
13+
use ReflectionUnionType;
14+
use SplFileObject;
1015

1116
class ModelInspector extends EloquentModelInspector
1217
{
@@ -22,4 +27,141 @@ public function __construct(?Application $app = null)
2227

2328
parent::__construct($app ?? app());
2429
}
30+
31+
/**
32+
* Get the relations from the given model.
33+
*
34+
* @param \Illuminate\Database\Eloquent\Model $model
35+
* @return \Illuminate\Support\Collection
36+
*/
37+
protected function getRelations($model)
38+
{
39+
return (new \Illuminate\Support\Collection(get_class_methods($model)))
40+
->map(fn ($method) => new ReflectionMethod($model, $method))
41+
->reject(
42+
fn (ReflectionMethod $method) => $method->isStatic()
43+
|| $method->isAbstract()
44+
|| $method->getDeclaringClass()->getName() === \Illuminate\Database\Eloquent\Model::class
45+
|| $method->getNumberOfParameters() > 0
46+
)
47+
->filter(function (ReflectionMethod $method) {
48+
if ($method->getReturnType() instanceof ReflectionNamedType
49+
&& is_subclass_of($method->getReturnType()->getName(), \Illuminate\Database\Eloquent\Relations\Relation::class)) {
50+
return true;
51+
}
52+
53+
$file = new SplFileObject($method->getFileName());
54+
$file->seek($method->getStartLine() - 1);
55+
$code = '';
56+
while ($file->key() < $method->getEndLine()) {
57+
$code .= mb_trim($file->current());
58+
$file->next();
59+
}
60+
61+
return (new \Illuminate\Support\Collection($this->relationMethods))
62+
->contains(fn ($relationMethod) => str_contains($code, '$this->'.$relationMethod.'('));
63+
})
64+
->map(function (ReflectionMethod $method) use ($model) {
65+
$relation = $method->invoke($model);
66+
67+
if (! $relation instanceof \Illuminate\Database\Eloquent\Relations\Relation) {
68+
return null;
69+
}
70+
71+
// Check if the return type is nullable
72+
$nullable = $this->isReturnTypeNullable($method);
73+
74+
$relationType = Str::afterLast(get_class($relation), '\\');
75+
$related = get_class($relation->getRelated());
76+
77+
// For MorphTo relations, extract the union types from the return type
78+
if ($relation instanceof MorphTo) {
79+
$related = $this->extractMorphToRelatedModels($method);
80+
}
81+
82+
return [
83+
'name' => $method->getName(),
84+
'type' => $relationType,
85+
'related' => $related,
86+
'nullable' => $nullable,
87+
];
88+
})
89+
->filter()
90+
->values();
91+
}
92+
93+
/**
94+
* Extract related models from a MorphTo relation's return type.
95+
*
96+
* @return string
97+
*/
98+
protected function extractMorphToRelatedModels(ReflectionMethod $method): string
99+
{
100+
$returnType = $method->getReturnType();
101+
102+
if (! $returnType instanceof ReflectionUnionType) {
103+
return get_class($this->getRelatedModelFromMorphTo($method));
104+
}
105+
106+
$types = [];
107+
foreach ($returnType->getTypes() as $type) {
108+
$typeName = $type->getName();
109+
// Skip the MorphTo type itself
110+
if ($typeName === 'Illuminate\Database\Eloquent\Relations\MorphTo') {
111+
continue;
112+
}
113+
$types[] = Str::afterLast($typeName, '\\');
114+
}
115+
116+
return implode('|', $types);
117+
}
118+
119+
/**
120+
* Get the related model from a MorphTo relation.
121+
*
122+
* @return \Illuminate\Database\Eloquent\Model|null
123+
*/
124+
protected function getRelatedModelFromMorphTo(ReflectionMethod $method): ?\Illuminate\Database\Eloquent\Model
125+
{
126+
try {
127+
$model = $method->getDeclaringClass()->newInstance();
128+
$relation = $method->invoke($model);
129+
130+
if ($relation instanceof MorphTo) {
131+
return $relation->getRelated();
132+
}
133+
} catch (\Exception $e) {
134+
// Return null if we can't instantiate the model
135+
}
136+
137+
return null;
138+
}
139+
140+
/**
141+
* Check if a method's return type is nullable.
142+
*/
143+
protected function isReturnTypeNullable(ReflectionMethod $method): bool
144+
{
145+
$returnType = $method->getReturnType();
146+
147+
if ($returnType === null) {
148+
return false;
149+
}
150+
151+
// Check if it's a union type containing null
152+
if ($returnType instanceof ReflectionUnionType) {
153+
foreach ($returnType->getTypes() as $type) {
154+
if ($type->getName() === 'null') {
155+
return true;
156+
}
157+
}
158+
}
159+
160+
// Check if it's a single nullable type
161+
if ($returnType instanceof ReflectionNamedType) {
162+
return $returnType->allowsNull();
163+
}
164+
165+
return false;
166+
}
25167
}

test/Tests/Feature/Actions/GetModelsTest.php

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,13 @@ public function test_action_can_find_all_models_in_project()
3232

3333
$foundModels = $action()->sortBy(fn ($file) => $file->getFilename())->values();
3434

35-
$this->assertCount(5, $foundModels);
35+
$this->assertCount(6, $foundModels);
3636
$this->assertStringContainsString('Complex.php', $foundModels[0]->getBasename());
3737
$this->assertStringContainsString('ComplexRelationship.php', $foundModels[1]->getBasename());
38-
$this->assertStringContainsString('Pivot.php', $foundModels[2]->getBasename());
39-
$this->assertStringContainsString('Team.php', $foundModels[3]->getBasename());
40-
$this->assertStringContainsString('User.php', $foundModels[4]->getBasename());
38+
$this->assertStringContainsString('MorphRelation.php', $foundModels[2]->getBasename());
39+
$this->assertStringContainsString('Pivot.php', $foundModels[3]->getBasename());
40+
$this->assertStringContainsString('Team.php', $foundModels[4]->getBasename());
41+
$this->assertStringContainsString('User.php', $foundModels[5]->getBasename());
4142
}
4243

4344
public function test_action_can_find_all_models_in_project_except_excluded_models()
@@ -46,11 +47,12 @@ public function test_action_can_find_all_models_in_project_except_excluded_model
4647

4748
$foundModels = $action(excludedModels: [User::class])->sortBy(fn ($file) => $file->getFilename())->values();
4849

49-
$this->assertCount(4, $foundModels);
50+
$this->assertCount(5, $foundModels);
5051
$this->assertStringContainsString('Complex.php', $foundModels[0]->getBasename());
5152
$this->assertStringContainsString('ComplexRelationship.php', $foundModels[1]->getBasename());
52-
$this->assertStringContainsString('Pivot.php', $foundModels[2]->getBasename());
53-
$this->assertStringContainsString('Team.php', $foundModels[3]->getBasename());
53+
$this->assertStringContainsString('MorphRelation.php', $foundModels[2]->getBasename());
54+
$this->assertStringContainsString('Pivot.php', $foundModels[3]->getBasename());
55+
$this->assertStringContainsString('Team.php', $foundModels[4]->getBasename());
5456
}
5557

5658
public function test_action_can_find_all_models_in_project_when_in_included_models()
@@ -68,7 +70,7 @@ public function test_action_can_find_additional_paths_model()
6870
$action = app(GetModels::class);
6971

7072
$foundModels = $action(additionalPaths: [base_path('other')])->map(fn ($file) => $file->getBasename());
71-
$this->assertCount(6, $foundModels);
73+
$this->assertCount(7, $foundModels);
7274
$this->assertContains('VendorComplex.php', $foundModels);
7375

7476
}

test/Tests/Feature/Actions/RunModelInspectorTest.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,20 @@ public function test_action_returns_null_on_non_existing_model()
4545

4646
$this->assertNull($result);
4747
}
48+
49+
public function test_action_can_extract_morph_to_union_types()
50+
{
51+
$result = app(RunModelInspector::class)('App\Models\MorphRelation');
52+
53+
$this->assertIsArray($result);
54+
$this->assertArrayHasKey('relations', $result);
55+
56+
$relations = $result['relations'];
57+
$this->assertCount(1, $relations);
58+
59+
$modelRelation = $relations->first();
60+
$this->assertEquals('model', $modelRelation['name']);
61+
$this->assertEquals('MorphTo', $modelRelation['type']);
62+
$this->assertEquals('User|Complex', $modelRelation['related']);
63+
}
4864
}

test/Tests/Feature/Actions/WriteRelationshipTest.php

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,115 @@ public function test_action_can_return_optional_exists_relationships()
110110

111111
$this->assertStringContainsString('notifications_exists?: boolean', $result);
112112
}
113+
114+
public function test_action_can_return_nullable_relationships()
115+
{
116+
$nullableRelation = [
117+
'name' => 'listing',
118+
'type' => 'BelongsTo',
119+
'related' => 'App\Models\Listing',
120+
'nullable' => true,
121+
];
122+
123+
$action = app(WriteRelationship::class);
124+
$result = $action(relation: $nullableRelation);
125+
126+
$this->assertStringContainsString('listing: Listing | null', $result);
127+
}
128+
129+
public function test_action_can_return_nullable_relationships_as_array()
130+
{
131+
$nullableRelation = [
132+
'name' => 'listing',
133+
'type' => 'BelongsTo',
134+
'related' => 'App\Models\Listing',
135+
'nullable' => true,
136+
];
137+
138+
$action = app(WriteRelationship::class);
139+
$result = $action(relation: $nullableRelation, jsonOutput: true);
140+
141+
$this->assertEquals([
142+
'name' => 'listing',
143+
'type' => 'Listing | null',
144+
], $result);
145+
}
146+
147+
public function test_action_can_return_non_nullable_relationships()
148+
{
149+
$nonNullableRelation = [
150+
'name' => 'user',
151+
'type' => 'BelongsTo',
152+
'related' => 'App\Models\User',
153+
'nullable' => false,
154+
];
155+
156+
$action = app(WriteRelationship::class);
157+
$result = $action(relation: $nonNullableRelation);
158+
159+
$this->assertStringContainsString('user: User', $result);
160+
$this->assertStringNotContainsString('user?: User', $result);
161+
$this->assertStringNotContainsString('user: User | null', $result);
162+
}
163+
164+
public function test_action_can_return_nullable_plural_relationships()
165+
{
166+
$nullableRelation = [
167+
'name' => 'tags',
168+
'type' => 'BelongsToMany',
169+
'related' => 'App\Models\Tag',
170+
'nullable' => true,
171+
];
172+
173+
$action = app(WriteRelationship::class);
174+
$result = $action(relation: $nullableRelation);
175+
176+
$this->assertStringContainsString('tags: Tag[] | null', $result);
177+
}
178+
179+
public function test_action_can_return_morph_to_union_type_relationships()
180+
{
181+
$morphToRelation = [
182+
'name' => 'model',
183+
'type' => 'MorphTo',
184+
'related' => 'User|Complex',
185+
];
186+
187+
$action = app(WriteRelationship::class);
188+
$result = $action(relation: $morphToRelation);
189+
190+
$this->assertStringContainsString('model: User|Complex', $result);
191+
}
192+
193+
public function test_action_can_return_morph_to_union_type_relationships_as_array()
194+
{
195+
$morphToRelation = [
196+
'name' => 'model',
197+
'type' => 'MorphTo',
198+
'related' => 'User|Complex',
199+
];
200+
201+
$action = app(WriteRelationship::class);
202+
$result = $action(relation: $morphToRelation, jsonOutput: true);
203+
204+
$this->assertEquals([
205+
'name' => 'model',
206+
'type' => 'User|Complex',
207+
], $result);
208+
}
209+
210+
public function test_action_can_return_nullable_morph_to_union_type_relationships()
211+
{
212+
$morphToRelation = [
213+
'name' => 'model',
214+
'type' => 'MorphTo',
215+
'related' => 'User|Complex',
216+
'nullable' => true,
217+
];
218+
219+
$action = app(WriteRelationship::class);
220+
$result = $action(relation: $morphToRelation);
221+
222+
$this->assertStringContainsString('model: User|Complex | null', $result);
223+
}
113224
}

0 commit comments

Comments
 (0)