diff --git a/src/Actions/WriteRelationship.php b/src/Actions/WriteRelationship.php index 22555a3..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; @@ -13,7 +14,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 @@ -21,14 +22,25 @@ 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'; + } if (in_array($relation['type'], Config::get('modeltyper.custom_relationships.singular', []))) { $relationType = Str::singular($relation['type']); @@ -45,6 +57,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..fc12db7 100644 --- a/src/Overrides/ModelInspector.php +++ b/src/Overrides/ModelInspector.php @@ -4,9 +4,14 @@ 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; +use ReflectionMethod; +use ReflectionNamedType; +use ReflectionUnionType; +use SplFileObject; class ModelInspector extends EloquentModelInspector { @@ -22,4 +27,141 @@ 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); + + $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' => $relationType, + 'related' => $related, + 'nullable' => $nullable, + ]; + }) + ->filter() + ->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. + */ + 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; + } } 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 eb6e5f9..50f702f 100644 --- a/test/Tests/Feature/Actions/WriteRelationshipTest.php +++ b/test/Tests/Feature/Actions/WriteRelationshipTest.php @@ -110,4 +110,115 @@ 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); + } + + 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(); + } +}