diff --git a/src/Actions/WriteRelationship.php b/src/Actions/WriteRelationship.php index 22555a3..61eb512 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,20 @@ public function __invoke(array $relation, string $indent = '', bool $jsonOutput $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) . '[]'), + '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 +51,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; + } } 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); + } }