From d6f02d5ca861099650ac13bf1fd4261270d92ebb Mon Sep 17 00:00:00 2001 From: Fredrik Livijn Date: Fri, 23 Jan 2026 09:01:33 +0100 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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); - } }