Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/Actions/WriteRelationship.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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']);
}
Expand All @@ -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;
}
}
86 changes: 86 additions & 0 deletions src/Overrides/ModelInspector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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;
}
}
65 changes: 65 additions & 0 deletions test/Tests/Feature/Actions/WriteRelationshipTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}