Skip to content
Merged
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
28 changes: 20 additions & 8 deletions src/Actions/WriteRelationship.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -13,22 +14,33 @@ 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
{
$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']);
Expand All @@ -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;
}
}
142 changes: 142 additions & 0 deletions src/Overrides/ModelInspector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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;
}
}
18 changes: 10 additions & 8 deletions test/Tests/Feature/Actions/GetModelsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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);

}
Expand Down
16 changes: 16 additions & 0 deletions test/Tests/Feature/Actions/RunModelInspectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
}
}
111 changes: 111 additions & 0 deletions test/Tests/Feature/Actions/WriteRelationshipTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading