From d6f02d5ca861099650ac13bf1fd4261270d92ebb Mon Sep 17 00:00:00 2001 From: Fredrik Livijn Date: Fri, 23 Jan 2026 09:01:33 +0100 Subject: [PATCH 1/6] 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) --- src/Actions/WriteRelationship.php | 16 ++++-- src/Overrides/ModelInspector.php | 86 +++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/src/Actions/WriteRelationship.php b/src/Actions/WriteRelationship.php index 22555a3..f360d6d 100644 --- a/src/Actions/WriteRelationship.php +++ b/src/Actions/WriteRelationship.php @@ -13,7 +13,7 @@ class WriteRelationship /** * Write the relationship to the output. * - * @param array{name: string, type: string, related:string} $relation + * @param array{name: string, type: string, related:string, nullable?: bool} $relation * @return array{type: string, name: string}|string */ public function __invoke(array $relation, string $indent = '', bool $jsonOutput = false, bool $optionalRelation = false, bool $plurals = false): array|string @@ -22,14 +22,22 @@ public function __invoke(array $relation, string $indent = '', bool $jsonOutput $name = app(MatchCase::class)($case, $relation['name']); $relatedModel = $this->getClassName($relation['related']); - $optional = $optionalRelation ? '?' : ''; + + // Check if the relation is nullable (either from the return type or from config) + $isNullable = $relation['nullable'] ?? false; + $optional = ($optionalRelation || $isNullable) ? '?' : ''; $relationType = match ($relation['type']) { - 'BelongsToMany', 'HasMany', 'HasManyThrough', 'MorphToMany', 'MorphMany', 'MorphedByMany' => $plurals === true ? Str::plural($relatedModel) : (Str::singular($relatedModel) . '[]'), + 'BelongsToMany', 'HasMany', 'HasManyThrough', 'MorphToMany', 'MorphMany', 'MorphedByMany' => $plurals === true ? Str::plural($relatedModel) : (Str::singular($relatedModel).'[]'), 'BelongsTo', 'HasOne', 'HasOneThrough', 'MorphOne', 'MorphTo' => Str::singular($relatedModel), default => $relatedModel, }; + // Add | null to the type if it's nullable and not already optional + if ($isNullable && ! $optional) { + $relationType .= ' | null'; + } + if (in_array($relation['type'], Config::get('modeltyper.custom_relationships.singular', []))) { $relationType = Str::singular($relation['type']); } @@ -45,6 +53,6 @@ public function __invoke(array $relation, string $indent = '', bool $jsonOutput ]; } - return "{$indent} {$name}{$optional}: {$relationType}" . PHP_EOL; + return "{$indent} {$name}{$optional}: {$relationType}".PHP_EOL; } } diff --git a/src/Overrides/ModelInspector.php b/src/Overrides/ModelInspector.php index 15a990b..67bb72c 100644 --- a/src/Overrides/ModelInspector.php +++ b/src/Overrides/ModelInspector.php @@ -7,6 +7,10 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\Config; use Illuminate\Support\Str; +use ReflectionMethod; +use ReflectionNamedType; +use ReflectionUnionType; +use SplFileObject; class ModelInspector extends EloquentModelInspector { @@ -22,4 +26,86 @@ public function __construct(?Application $app = null) parent::__construct($app ?? app()); } + + /** + * Get the relations from the given model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return \Illuminate\Support\Collection + */ + protected function getRelations($model) + { + return (new \Illuminate\Support\Collection(get_class_methods($model))) + ->map(fn ($method) => new ReflectionMethod($model, $method)) + ->reject( + fn (ReflectionMethod $method) => $method->isStatic() + || $method->isAbstract() + || $method->getDeclaringClass()->getName() === \Illuminate\Database\Eloquent\Model::class + || $method->getNumberOfParameters() > 0 + ) + ->filter(function (ReflectionMethod $method) { + if ($method->getReturnType() instanceof ReflectionNamedType + && is_subclass_of($method->getReturnType()->getName(), \Illuminate\Database\Eloquent\Relations\Relation::class)) { + return true; + } + + $file = new SplFileObject($method->getFileName()); + $file->seek($method->getStartLine() - 1); + $code = ''; + while ($file->key() < $method->getEndLine()) { + $code .= mb_trim($file->current()); + $file->next(); + } + + return (new \Illuminate\Support\Collection($this->relationMethods)) + ->contains(fn ($relationMethod) => str_contains($code, '$this->'.$relationMethod.'(')); + }) + ->map(function (ReflectionMethod $method) use ($model) { + $relation = $method->invoke($model); + + if (! $relation instanceof \Illuminate\Database\Eloquent\Relations\Relation) { + return null; + } + + // Check if the return type is nullable + $nullable = $this->isReturnTypeNullable($method); + + return [ + 'name' => $method->getName(), + 'type' => Str::afterLast(get_class($relation), '\\'), + 'related' => get_class($relation->getRelated()), + 'nullable' => $nullable, + ]; + }) + ->filter() + ->values(); + } + + /** + * Check if a method's return type is nullable. + */ + protected function isReturnTypeNullable(ReflectionMethod $method): bool + { + $returnType = $method->getReturnType(); + + if ($returnType === null) { + return false; + } + + // Check if it's a union type containing null + if ($returnType instanceof ReflectionUnionType) { + foreach ($returnType->getTypes() as $type) { + if ($type->getName() === 'null') { + return true; + } + } + } + + // Check if it's a single nullable type + if ($returnType instanceof ReflectionNamedType) { + return $returnType->allowsNull(); + } + + return false; + } } From 7108360dcc9bee8b6935845fe00a7d672cbcc1f8 Mon Sep 17 00:00:00 2001 From: Fredrik Livijn Date: Fri, 23 Jan 2026 09:32:56 +0100 Subject: [PATCH 2/6] 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 --- src/Actions/WriteRelationship.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Actions/WriteRelationship.php b/src/Actions/WriteRelationship.php index f360d6d..a69d3c3 100644 --- a/src/Actions/WriteRelationship.php +++ b/src/Actions/WriteRelationship.php @@ -25,7 +25,7 @@ public function __invoke(array $relation, string $indent = '', bool $jsonOutput // Check if the relation is nullable (either from the return type or from config) $isNullable = $relation['nullable'] ?? false; - $optional = ($optionalRelation || $isNullable) ? '?' : ''; + $optional = $optionalRelation ? '?' : ''; $relationType = match ($relation['type']) { 'BelongsToMany', 'HasMany', 'HasManyThrough', 'MorphToMany', 'MorphMany', 'MorphedByMany' => $plurals === true ? Str::plural($relatedModel) : (Str::singular($relatedModel).'[]'), @@ -33,8 +33,8 @@ public function __invoke(array $relation, string $indent = '', bool $jsonOutput default => $relatedModel, }; - // Add | null to the type if it's nullable and not already optional - if ($isNullable && ! $optional) { + // Add | null to the type if it's nullable + if ($isNullable) { $relationType .= ' | null'; } From 7ad3449efc66f1bbeef28e214f6d84ca38c34461 Mon Sep 17 00:00:00 2001 From: Fredrik Livijn Date: Fri, 23 Jan 2026 09:51:38 +0100 Subject: [PATCH 3/6] 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 --- src/Overrides/ModelInspector.php | 2 +- .../Feature/Actions/WriteRelationshipTest.php | 65 +++++++++++++++++++ .../Feature/Overrides/ModelInspectorTest.php | 52 +++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) diff --git a/src/Overrides/ModelInspector.php b/src/Overrides/ModelInspector.php index 67bb72c..a0f0758 100644 --- a/src/Overrides/ModelInspector.php +++ b/src/Overrides/ModelInspector.php @@ -84,7 +84,7 @@ protected function getRelations($model) /** * Check if a method's return type is nullable. */ - protected function isReturnTypeNullable(ReflectionMethod $method): bool + public function isReturnTypeNullable(ReflectionMethod $method): bool { $returnType = $method->getReturnType(); diff --git a/test/Tests/Feature/Actions/WriteRelationshipTest.php b/test/Tests/Feature/Actions/WriteRelationshipTest.php index eb6e5f9..bcc3d87 100644 --- a/test/Tests/Feature/Actions/WriteRelationshipTest.php +++ b/test/Tests/Feature/Actions/WriteRelationshipTest.php @@ -110,4 +110,69 @@ public function test_action_can_return_optional_exists_relationships() $this->assertStringContainsString('notifications_exists?: boolean', $result); } + + public function test_action_can_return_nullable_relationships() + { + $nullableRelation = [ + 'name' => 'listing', + 'type' => 'BelongsTo', + 'related' => 'App\Models\Listing', + 'nullable' => true, + ]; + + $action = app(WriteRelationship::class); + $result = $action(relation: $nullableRelation); + + $this->assertStringContainsString('listing: Listing | null', $result); + } + + public function test_action_can_return_nullable_relationships_as_array() + { + $nullableRelation = [ + 'name' => 'listing', + 'type' => 'BelongsTo', + 'related' => 'App\Models\Listing', + 'nullable' => true, + ]; + + $action = app(WriteRelationship::class); + $result = $action(relation: $nullableRelation, jsonOutput: true); + + $this->assertEquals([ + 'name' => 'listing', + 'type' => 'Listing | null', + ], $result); + } + + public function test_action_can_return_non_nullable_relationships() + { + $nonNullableRelation = [ + 'name' => 'user', + 'type' => 'BelongsTo', + 'related' => 'App\Models\User', + 'nullable' => false, + ]; + + $action = app(WriteRelationship::class); + $result = $action(relation: $nonNullableRelation); + + $this->assertStringContainsString('user: User', $result); + $this->assertStringNotContainsString('user?: User', $result); + $this->assertStringNotContainsString('user: User | null', $result); + } + + public function test_action_can_return_nullable_plural_relationships() + { + $nullableRelation = [ + 'name' => 'tags', + 'type' => 'BelongsToMany', + 'related' => 'App\Models\Tag', + 'nullable' => true, + ]; + + $action = app(WriteRelationship::class); + $result = $action(relation: $nullableRelation); + + $this->assertStringContainsString('tags: Tag[] | null', $result); + } } diff --git a/test/Tests/Feature/Overrides/ModelInspectorTest.php b/test/Tests/Feature/Overrides/ModelInspectorTest.php index 8b87b94..b57ef83 100644 --- a/test/Tests/Feature/Overrides/ModelInspectorTest.php +++ b/test/Tests/Feature/Overrides/ModelInspectorTest.php @@ -32,4 +32,56 @@ public function test_override_can_set_custom_relationships() $this->assertContains(needle: 'hasCustomRelationship', haystack: $relationMethods); $this->assertContains(needle: 'belongsToCustomRelationship', haystack: $relationMethods); } + + public function test_override_can_detect_nullable_return_types() + { + $override = app(ModelInspector::class); + + // Test the isReturnTypeNullable method with a mock reflection method + $reflectionMethod = $this->createMock(\ReflectionMethod::class); + + // Test with nullable union type (e.g., BelongsTo|Listing|null) + $nullableUnionType = $this->createMock(\ReflectionUnionType::class); + $nullType = $this->createMock(\ReflectionNamedType::class); + $nullType->method('getName')->willReturn('null'); + + $nullableUnionType->method('getTypes')->willReturn([$nullType]); + + $reflectionMethod->method('getReturnType')->willReturn($nullableUnionType); + + $result = $override->isReturnTypeNullable($reflectionMethod); + $this->assertTrue($result); + } + + public function test_override_can_detect_non_nullable_return_types() + { + $override = app(ModelInspector::class); + + // Test with non-nullable type + $reflectionMethod = $this->createMock(\ReflectionMethod::class); + $nonNullableType = $this->createMock(\ReflectionNamedType::class); + $nonNullableType->method('getName')->willReturn('BelongsTo'); + $nonNullableType->method('allowsNull')->willReturn(false); + + $reflectionMethod->method('getReturnType')->willReturn($nonNullableType); + + $result = $override->isReturnTypeNullable($reflectionMethod); + $this->assertFalse($result); + } + + public function test_override_can_detect_nullable_named_type() + { + $override = app(ModelInspector::class); + + // Test with nullable named type (e.g., ?BelongsTo) + $reflectionMethod = $this->createMock(\ReflectionMethod::class); + $nullableType = $this->createMock(\ReflectionNamedType::class); + $nullableType->method('getName')->willReturn('BelongsTo'); + $nullableType->method('allowsNull')->willReturn(true); + + $reflectionMethod->method('getReturnType')->willReturn($nullableType); + + $result = $override->isReturnTypeNullable($reflectionMethod); + $this->assertTrue($result); + } } From a6f69ab7db0789f240f9345fe5d2c2ff73605e21 Mon Sep 17 00:00:00 2001 From: Fredrik Livijn Date: Fri, 23 Jan 2026 09:56:10 +0100 Subject: [PATCH 4/6] 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 --- src/Actions/WriteRelationship.php | 2 - src/Overrides/ModelInspector.php | 2 +- .../Feature/Overrides/ModelInspectorTest.php | 52 ------------------- 3 files changed, 1 insertion(+), 55 deletions(-) diff --git a/src/Actions/WriteRelationship.php b/src/Actions/WriteRelationship.php index a69d3c3..61eb512 100644 --- a/src/Actions/WriteRelationship.php +++ b/src/Actions/WriteRelationship.php @@ -23,7 +23,6 @@ public function __invoke(array $relation, string $indent = '', bool $jsonOutput $relatedModel = $this->getClassName($relation['related']); - // Check if the relation is nullable (either from the return type or from config) $isNullable = $relation['nullable'] ?? false; $optional = $optionalRelation ? '?' : ''; @@ -33,7 +32,6 @@ public function __invoke(array $relation, string $indent = '', bool $jsonOutput default => $relatedModel, }; - // Add | null to the type if it's nullable if ($isNullable) { $relationType .= ' | null'; } diff --git a/src/Overrides/ModelInspector.php b/src/Overrides/ModelInspector.php index a0f0758..67bb72c 100644 --- a/src/Overrides/ModelInspector.php +++ b/src/Overrides/ModelInspector.php @@ -84,7 +84,7 @@ protected function getRelations($model) /** * Check if a method's return type is nullable. */ - public function isReturnTypeNullable(ReflectionMethod $method): bool + protected function isReturnTypeNullable(ReflectionMethod $method): bool { $returnType = $method->getReturnType(); diff --git a/test/Tests/Feature/Overrides/ModelInspectorTest.php b/test/Tests/Feature/Overrides/ModelInspectorTest.php index b57ef83..8b87b94 100644 --- a/test/Tests/Feature/Overrides/ModelInspectorTest.php +++ b/test/Tests/Feature/Overrides/ModelInspectorTest.php @@ -32,56 +32,4 @@ public function test_override_can_set_custom_relationships() $this->assertContains(needle: 'hasCustomRelationship', haystack: $relationMethods); $this->assertContains(needle: 'belongsToCustomRelationship', haystack: $relationMethods); } - - public function test_override_can_detect_nullable_return_types() - { - $override = app(ModelInspector::class); - - // Test the isReturnTypeNullable method with a mock reflection method - $reflectionMethod = $this->createMock(\ReflectionMethod::class); - - // Test with nullable union type (e.g., BelongsTo|Listing|null) - $nullableUnionType = $this->createMock(\ReflectionUnionType::class); - $nullType = $this->createMock(\ReflectionNamedType::class); - $nullType->method('getName')->willReturn('null'); - - $nullableUnionType->method('getTypes')->willReturn([$nullType]); - - $reflectionMethod->method('getReturnType')->willReturn($nullableUnionType); - - $result = $override->isReturnTypeNullable($reflectionMethod); - $this->assertTrue($result); - } - - public function test_override_can_detect_non_nullable_return_types() - { - $override = app(ModelInspector::class); - - // Test with non-nullable type - $reflectionMethod = $this->createMock(\ReflectionMethod::class); - $nonNullableType = $this->createMock(\ReflectionNamedType::class); - $nonNullableType->method('getName')->willReturn('BelongsTo'); - $nonNullableType->method('allowsNull')->willReturn(false); - - $reflectionMethod->method('getReturnType')->willReturn($nonNullableType); - - $result = $override->isReturnTypeNullable($reflectionMethod); - $this->assertFalse($result); - } - - public function test_override_can_detect_nullable_named_type() - { - $override = app(ModelInspector::class); - - // Test with nullable named type (e.g., ?BelongsTo) - $reflectionMethod = $this->createMock(\ReflectionMethod::class); - $nullableType = $this->createMock(\ReflectionNamedType::class); - $nullableType->method('getName')->willReturn('BelongsTo'); - $nullableType->method('allowsNull')->willReturn(true); - - $reflectionMethod->method('getReturnType')->willReturn($nullableType); - - $result = $override->isReturnTypeNullable($reflectionMethod); - $this->assertTrue($result); - } } From 4613988e70fd51d11878ba163986c02c18026c7b Mon Sep 17 00:00:00 2001 From: Fredrik Livijn Date: Fri, 23 Jan 2026 10:29:32 +0100 Subject: [PATCH 5/6] wip --- src/Actions/WriteRelationship.php | 20 ++++--- src/Overrides/ModelInspector.php | 60 ++++++++++++++++++- test/Tests/Feature/Actions/GetModelsTest.php | 18 +++--- .../Feature/Actions/RunModelInspectorTest.php | 16 +++++ .../Feature/Actions/WriteRelationshipTest.php | 47 ++++++++++++++- .../app/Models/MorphRelation.php | 14 +++++ 6 files changed, 157 insertions(+), 18 deletions(-) create mode 100644 test/laravel-skeleton/app/Models/MorphRelation.php diff --git a/src/Actions/WriteRelationship.php b/src/Actions/WriteRelationship.php index 61eb512..1fbdbf6 100644 --- a/src/Actions/WriteRelationship.php +++ b/src/Actions/WriteRelationship.php @@ -3,6 +3,7 @@ namespace FumeApp\ModelTyper\Actions; use FumeApp\ModelTyper\Traits\ClassBaseName; +use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Support\Facades\Config; use Illuminate\Support\Str; @@ -21,16 +22,21 @@ public function __invoke(array $relation, string $indent = '', bool $jsonOutput $case = Config::get('modeltyper.case.relations', 'snake'); $name = app(MatchCase::class)($case, $relation['name']); - $relatedModel = $this->getClassName($relation['related']); - $isNullable = $relation['nullable'] ?? false; $optional = $optionalRelation ? '?' : ''; - $relationType = match ($relation['type']) { - 'BelongsToMany', 'HasMany', 'HasManyThrough', 'MorphToMany', 'MorphMany', 'MorphedByMany' => $plurals === true ? Str::plural($relatedModel) : (Str::singular($relatedModel).'[]'), - 'BelongsTo', 'HasOne', 'HasOneThrough', 'MorphOne', 'MorphTo' => Str::singular($relatedModel), - default => $relatedModel, - }; + // For MorphTo relations with union types, use the related string directly + if ($relation['type'] === 'MorphTo' && str_contains($relation['related'], '|')) { + $relationType = $relation['related']; + } else { + $relatedModel = $this->getClassName($relation['related']); + + $relationType = match ($relation['type']) { + 'BelongsToMany', 'HasMany', 'HasManyThrough', 'MorphToMany', 'MorphMany', 'MorphedByMany' => $plurals === true ? Str::plural($relatedModel) : (Str::singular($relatedModel).'[]'), + 'BelongsTo', 'HasOne', 'HasOneThrough', 'MorphOne', 'MorphTo' => Str::singular($relatedModel), + default => $relatedModel, + }; + } if ($isNullable) { $relationType .= ' | null'; diff --git a/src/Overrides/ModelInspector.php b/src/Overrides/ModelInspector.php index 67bb72c..fc12db7 100644 --- a/src/Overrides/ModelInspector.php +++ b/src/Overrides/ModelInspector.php @@ -4,6 +4,7 @@ use Illuminate\Contracts\Foundation\Application; use Illuminate\Database\Eloquent\ModelInspector as EloquentModelInspector; +use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Config; use Illuminate\Support\Str; @@ -70,10 +71,18 @@ protected function getRelations($model) // Check if the return type is nullable $nullable = $this->isReturnTypeNullable($method); + $relationType = Str::afterLast(get_class($relation), '\\'); + $related = get_class($relation->getRelated()); + + // For MorphTo relations, extract the union types from the return type + if ($relation instanceof MorphTo) { + $related = $this->extractMorphToRelatedModels($method); + } + return [ 'name' => $method->getName(), - 'type' => Str::afterLast(get_class($relation), '\\'), - 'related' => get_class($relation->getRelated()), + 'type' => $relationType, + 'related' => $related, 'nullable' => $nullable, ]; }) @@ -81,6 +90,53 @@ protected function getRelations($model) ->values(); } + /** + * Extract related models from a MorphTo relation's return type. + * + * @return string + */ + protected function extractMorphToRelatedModels(ReflectionMethod $method): string + { + $returnType = $method->getReturnType(); + + if (! $returnType instanceof ReflectionUnionType) { + return get_class($this->getRelatedModelFromMorphTo($method)); + } + + $types = []; + foreach ($returnType->getTypes() as $type) { + $typeName = $type->getName(); + // Skip the MorphTo type itself + if ($typeName === 'Illuminate\Database\Eloquent\Relations\MorphTo') { + continue; + } + $types[] = Str::afterLast($typeName, '\\'); + } + + return implode('|', $types); + } + + /** + * Get the related model from a MorphTo relation. + * + * @return \Illuminate\Database\Eloquent\Model|null + */ + protected function getRelatedModelFromMorphTo(ReflectionMethod $method): ?\Illuminate\Database\Eloquent\Model + { + try { + $model = $method->getDeclaringClass()->newInstance(); + $relation = $method->invoke($model); + + if ($relation instanceof MorphTo) { + return $relation->getRelated(); + } + } catch (\Exception $e) { + // Return null if we can't instantiate the model + } + + return null; + } + /** * Check if a method's return type is nullable. */ diff --git a/test/Tests/Feature/Actions/GetModelsTest.php b/test/Tests/Feature/Actions/GetModelsTest.php index 3dc0446..c4584ce 100644 --- a/test/Tests/Feature/Actions/GetModelsTest.php +++ b/test/Tests/Feature/Actions/GetModelsTest.php @@ -32,12 +32,13 @@ public function test_action_can_find_all_models_in_project() $foundModels = $action()->sortBy(fn ($file) => $file->getFilename())->values(); - $this->assertCount(5, $foundModels); + $this->assertCount(6, $foundModels); $this->assertStringContainsString('Complex.php', $foundModels[0]->getBasename()); $this->assertStringContainsString('ComplexRelationship.php', $foundModels[1]->getBasename()); - $this->assertStringContainsString('Pivot.php', $foundModels[2]->getBasename()); - $this->assertStringContainsString('Team.php', $foundModels[3]->getBasename()); - $this->assertStringContainsString('User.php', $foundModels[4]->getBasename()); + $this->assertStringContainsString('MorphRelation.php', $foundModels[2]->getBasename()); + $this->assertStringContainsString('Pivot.php', $foundModels[3]->getBasename()); + $this->assertStringContainsString('Team.php', $foundModels[4]->getBasename()); + $this->assertStringContainsString('User.php', $foundModels[5]->getBasename()); } 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 $foundModels = $action(excludedModels: [User::class])->sortBy(fn ($file) => $file->getFilename())->values(); - $this->assertCount(4, $foundModels); + $this->assertCount(5, $foundModels); $this->assertStringContainsString('Complex.php', $foundModels[0]->getBasename()); $this->assertStringContainsString('ComplexRelationship.php', $foundModels[1]->getBasename()); - $this->assertStringContainsString('Pivot.php', $foundModels[2]->getBasename()); - $this->assertStringContainsString('Team.php', $foundModels[3]->getBasename()); + $this->assertStringContainsString('MorphRelation.php', $foundModels[2]->getBasename()); + $this->assertStringContainsString('Pivot.php', $foundModels[3]->getBasename()); + $this->assertStringContainsString('Team.php', $foundModels[4]->getBasename()); } 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() $action = app(GetModels::class); $foundModels = $action(additionalPaths: [base_path('other')])->map(fn ($file) => $file->getBasename()); - $this->assertCount(6, $foundModels); + $this->assertCount(7, $foundModels); $this->assertContains('VendorComplex.php', $foundModels); } diff --git a/test/Tests/Feature/Actions/RunModelInspectorTest.php b/test/Tests/Feature/Actions/RunModelInspectorTest.php index 12b0d50..9f75366 100644 --- a/test/Tests/Feature/Actions/RunModelInspectorTest.php +++ b/test/Tests/Feature/Actions/RunModelInspectorTest.php @@ -45,4 +45,20 @@ public function test_action_returns_null_on_non_existing_model() $this->assertNull($result); } + + public function test_action_can_extract_morph_to_union_types() + { + $result = app(RunModelInspector::class)('App\Models\MorphRelation'); + + $this->assertIsArray($result); + $this->assertArrayHasKey('relations', $result); + + $relations = $result['relations']; + $this->assertCount(1, $relations); + + $modelRelation = $relations->first(); + $this->assertEquals('model', $modelRelation['name']); + $this->assertEquals('MorphTo', $modelRelation['type']); + $this->assertEquals('User|Complex', $modelRelation['related']); + } } diff --git a/test/Tests/Feature/Actions/WriteRelationshipTest.php b/test/Tests/Feature/Actions/WriteRelationshipTest.php index bcc3d87..ed55412 100644 --- a/test/Tests/Feature/Actions/WriteRelationshipTest.php +++ b/test/Tests/Feature/Actions/WriteRelationshipTest.php @@ -173,6 +173,51 @@ public function test_action_can_return_nullable_plural_relationships() $action = app(WriteRelationship::class); $result = $action(relation: $nullableRelation); - $this->assertStringContainsString('tags: Tag[] | null', $result); + } + + public function test_action_can_return_morph_to_union_type_relationships() + { + $morphToRelation = [ + 'name' => 'model', + 'type' => 'MorphTo', + 'related' => 'User|Complex', + ]; + + $action = app(WriteRelationship::class); + $result = $action(relation: $morphToRelation); + + $this->assertStringContainsString('model: User|Complex', $result); + } + + public function test_action_can_return_morph_to_union_type_relationships_as_array() + { + $morphToRelation = [ + 'name' => 'model', + 'type' => 'MorphTo', + 'related' => 'User|Complex', + ]; + + $action = app(WriteRelationship::class); + $result = $action(relation: $morphToRelation, jsonOutput: true); + + $this->assertEquals([ + 'name' => 'model', + 'type' => 'User|Complex', + ], $result); + } + + public function test_action_can_return_nullable_morph_to_union_type_relationships() + { + $morphToRelation = [ + 'name' => 'model', + 'type' => 'MorphTo', + 'related' => 'User|Complex', + 'nullable' => true, + ]; + + $action = app(WriteRelationship::class); + $result = $action(relation: $morphToRelation); + + $this->assertStringContainsString('model: User|Complex | null', $result); } } diff --git a/test/laravel-skeleton/app/Models/MorphRelation.php b/test/laravel-skeleton/app/Models/MorphRelation.php new file mode 100644 index 0000000..d7716e0 --- /dev/null +++ b/test/laravel-skeleton/app/Models/MorphRelation.php @@ -0,0 +1,14 @@ +morphTo(); + } +} From f3e531d2e813be57b54c832180489d7c9a580ebc Mon Sep 17 00:00:00 2001 From: tanner Date: Sat, 24 Jan 2026 14:12:32 -0500 Subject: [PATCH 6/6] perform assertion --- test/Tests/Feature/Actions/WriteRelationshipTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/test/Tests/Feature/Actions/WriteRelationshipTest.php b/test/Tests/Feature/Actions/WriteRelationshipTest.php index ed55412..50f702f 100644 --- a/test/Tests/Feature/Actions/WriteRelationshipTest.php +++ b/test/Tests/Feature/Actions/WriteRelationshipTest.php @@ -173,6 +173,7 @@ public function test_action_can_return_nullable_plural_relationships() $action = app(WriteRelationship::class); $result = $action(relation: $nullableRelation); + $this->assertStringContainsString('tags: Tag[] | null', $result); } public function test_action_can_return_morph_to_union_type_relationships()