Repository: laravel-shift/blueprint (php)

Issue:
[Proposal] Support fully-qualified class names in relationships
First of all: 100% willing to create a complete PR for this, with tests and docs. Just saying 😀

### Synopsis:

When creating Models with relations, we found it's impossible to reference existing models that are in different directories than the configured one (`app` or `app\Models`, whatever):

You can do:
```yaml
models:
  Post:
    title: string:400
    published_at: timestamp nullable
    relationships:
      hasMany: Comment
      belongsToMany: Media, Site
```

But you _can't_ do:
```diff
models:
  Post:
    title: string:400
    published_at: timestamp nullable
    relationships:
-     hasMany: Comment
+     hasMany: Spatie\Comments\Models\Comment
      belongsToMany: Media, Site
```

because the generated relationship will then point to `App\Models\Spatie\Comments\Models\Comment`, which is not what you want... 

The problem happens:
- when you want to point a relationship to a model from a third-party package;
- when you have models both inside `app` and inside `app\Models` (eg. `app\User`, but everything else in `app\Models`);
- when you're doing domain-driven development and your models live in `app\Domains\Store\Models\Comment`;

What the dev has to do in this case is:
- generate the model like this, with the wrong relationship;
- manually edit the file to point to the correct class;
 
Which is... fine, I guess. But I think Blueprint can do better, and we may have an intuitive syntax for it! 😀🤞

### Proposed Syntax:

The _root cause_ we identified is that the developer has no way to tell Blueprint "_I know what I'm doing. Link to this specific class, don't make any assumptions about the namespace_". And the solution we found would be for **Blueprint to also support _fully qualified namespaces_**, as a way to get around the configuration. A simple way to do that would be to... 🥁🥁🥁... **prefix the classname with a backslash**:
- `Comment` means using assumptions, so it'll use `App\Comment` or `App\Models\Comment` depending on the configuration file;
- `\Spatie\Comments\Models\Comment` means I know the _exact_ namespace of my related model, so use that one, no assumptions;

### Expected Behavior:

No change in the examples above. They still generate the same thing.

But this would also work:

```diff
models:
  Post:
    title: string:400
    published_at: timestamp nullable
    relationships:
-     hasMany: Comment
+     hasMany: \Spatie\Comments\Models\Comment
      belongsToMany: Media, Site
```

And the relationship will point to `\Spatie\Comments\Models\Comment::class`, not `App\Models\Spatie\Comments\Models\Comment`.

----

Right now this is the only place we needed the change (relationships), so that's what we've worked to solve. But if you like the `\` syntax... I think we can expand on it in the future, add support for fully-qualified-classnames for Models, Controllers, etc.

@jasonmccreary - **please let me know if you like the `\` syntax. And if you're willing to merge this in the current Blueprint version if we submit a proper PR. Do we have a green light to work on this? Yellow? Red?** 😀 Thanks 🙏

PS. We have a working prototype that we can polish, we'll submit quality PRs, tests, docs - so we'll make it easy for you to merge as a non-breaking change. You don't like something, you ask us to refactor/change, np. We can also promise to maintain that bit of code in the future, for years. But. I totally understand if you don't want Blueprint to support this at all - I'm no stranger to feature creep 🙄 I think this is something worth having, you might not...

Pull request:
Adds support for FQCN to model relationships
I'm submitting the PR for @pxpm - he's the one who's made all this happen 🙏

Fixes #469 . Allows Blueprint to generate models with relationships to third-party packages (or oddly-placed models).

The developer can type `belongsTo: \App\Models\Cart\Entry` in the YAML file, as the relationship. Notice the `\` prefix - that'll tell Blueprint that it's a fully-qualified class name, so it won't process it any further - it won't prefix `App` or `App\Models` to it, it'll just use it as-is. 

Patch:
diff --git a/src/Generators/ModelGenerator.php b/src/Generators/ModelGenerator.php
index bb64a83..b4d394d 100644
--- a/src/Generators/ModelGenerator.php
+++ b/src/Generators/ModelGenerator.php
@@ -190,16 +190,20 @@ class ModelGenerator implements Generator
 
         foreach ($model->relationships() as $type => $references) {
             foreach ($references as $reference) {
+                // if reference starts with \ , we assume developer is using a fully namespaced model
+                $is_model_fqn = Str::startsWith($reference, '\\');
+
                 $custom_template = $template;
                 $key = null;
                 $class = null;
 
                 $column_name = $reference;
-                $method_name = Str::beforeLast($reference, '_id');
+                $method_name = $is_model_fqn ? Str::afterLast($reference, '\\') : Str::beforeLast($reference, '_id');
 
                 if (Str::contains($reference, ':')) {
                     [$foreign_reference, $column_name] = explode(':', $reference);
-                    $method_name = Str::beforeLast($column_name, '_id');
+
+                    $method_name = $is_model_fqn ? Str::afterLast($foreign_reference, '\\') : Str::beforeLast($column_name, '_id');
 
                     if (Str::contains($foreign_reference, '.')) {
                         [$class, $key] = explode('.', $foreign_reference);
@@ -207,34 +211,43 @@ class ModelGenerator implements Generator
                         if ($key === 'id') {
                             $key = null;
                         } else {
-                            $method_name = Str::lower($class);
+                            $method_name = $is_model_fqn ? Str::lower(Str::afterLast($class, '\\')) : Str::lower($class);
                         }
                     } else {
                         $class = $foreign_reference;
                     }
                 }
 
-                $class_name = Str::studly($class ?? $method_name);
-                $fqcn = $this->fullyQualifyModelReference($class_name) ?? $model->fullyQualifiedNamespace() . '\\' . $class_name;
+                // if full model namespace is proviced we will not try to infer it,
+                // we use the namespace as developer gives us
+                if ($is_model_fqn) {
+                    $fqcn = $class ?? $column_name;
+                    $class_name = Str::afterLast($fqcn, '\\');
+                }else{
+                    $class_name = Str::studly($class ?? $method_name);
+                    $fqcn = $this->fullyQualifyModelReference($class_name) ?? $model->fullyQualifiedNamespace() . '\\' . $class_name;
+                }
+
+                $fqcn = Str::startsWith($fqcn, '\\') ? $fqcn : '\\'.$fqcn;
 
                 if ($type === 'morphTo') {
                     $relationship = sprintf('$this->%s()', $type);
                 } elseif ($type === 'morphMany' || $type === 'morphOne') {
                     $relation = Str::lower(Str::singular($column_name)) . 'able';
-                    $relationship = sprintf('$this->%s(%s::class, \'%s\')', $type, '\\' . $fqcn, $relation);
+                    $relationship = sprintf('$this->%s(%s::class, \'%s\')', $type, $fqcn, $relation);
                 } elseif (!is_null($key)) {
-                    $relationship = sprintf('$this->%s(%s::class, \'%s\', \'%s\')', $type, '\\' . $fqcn, $column_name, $key);
+                    $relationship = sprintf('$this->%s(%s::class, \'%s\', \'%s\')', $type, $fqcn, $column_name, $key);
                 } elseif (!is_null($class) && $type === 'belongsToMany') {
-                    $relationship = sprintf('$this->%s(%s::class, \'%s\')', $type, '\\' . $fqcn, $column_name);
+                    $relationship = sprintf('$this->%s(%s::class, \'%s\')', $type, $fqcn, $column_name);
                     $column_name = $class;
                 } else {
-                    $relationship = sprintf('$this->%s(%s::class)', $type, '\\' . $fqcn);
+                    $relationship = sprintf('$this->%s(%s::class)', $type, $fqcn);
                 }
 
                 if ($type === 'morphTo') {
                     $method_name = Str::lower($class_name);
                 } elseif (in_array($type, ['hasMany', 'belongsToMany', 'morphMany'])) {
-                    $method_name = Str::plural($column_name);
+                    $method_name = Str::plural($is_model_fqn ? Str::afterLast($column_name, '\\') : $column_name);
                 }
 
                 if (Blueprint::supportsReturnTypeHits()) {


Test patch:
diff --git a/tests/Feature/Generators/ModelGeneratorTest.php b/tests/Feature/Generators/ModelGeneratorTest.php
index 33b2805..07f6659 100644
--- a/tests/Feature/Generators/ModelGeneratorTest.php
+++ b/tests/Feature/Generators/ModelGeneratorTest.php
@@ -268,6 +268,38 @@ class ModelGeneratorTest extends TestCase
         $this->assertEquals(['created' => ['app/Subscription.php']], $this->subject->output($tree));
     }
 
+    /**
+     * @test
+     * @environment-setup useLaravel8
+     */
+    public function output_generates_relationships_added_with_full_model_namespace()
+    {
+        $this->files->expects('stub')
+            ->with($this->modelStub)
+            ->andReturn($this->stub($this->modelStub));
+        $this->files->expects('stub')
+            ->with('model.fillable.stub')
+            ->andReturn($this->stub('model.fillable.stub'));
+        $this->files->expects('stub')
+            ->with('model.casts.stub')
+            ->andReturn($this->stub('model.casts.stub'));
+        $this->files->expects('stub')
+            ->with('model.method.stub')
+            ->andReturn($this->stub('model.method.stub'));
+
+        $this->files->expects('exists')
+            ->with('app')
+            ->andReturnTrue();
+
+        $this->files->expects('put')
+            ->with('app/Recurrency.php', $this->fixture('models/model-relationships-with-full-namespace-laravel8.php'));
+
+        $tokens = $this->blueprint->parse($this->fixture('drafts/model-relationships-with-full-model-namespaces.yaml'));
+        $tree = $this->blueprint->analyze($tokens);
+
+        $this->assertEquals(['created' => ['app/Recurrency.php']], $this->subject->output($tree));
+    }
+
     /**
      * @test
      * @environment-setup useLaravel8
diff --git a/tests/fixtures/drafts/model-relationships-with-full-model-namespaces.yaml b/tests/fixtures/drafts/model-relationships-with-full-model-namespaces.yaml
new file mode 100644
index 0000000..7d01604
--- /dev/null
+++ b/tests/fixtures/drafts/model-relationships-with-full-model-namespaces.yaml
@@ -0,0 +1,8 @@
+models:
+  Recurrency:
+    user_id: id
+    product_id: uuid
+    relationships:
+      belongsToMany: \Some\Package\Team
+      hasMany: \Other\Package\Order
+      hasOne: \Other\Package\Duration, \App\MyCustom\Folder\Transaction
diff --git a/tests/fixtures/models/model-relationships-with-full-namespace-laravel8.php b/tests/fixtures/models/model-relationships-with-full-namespace-laravel8.php
new file mode 100644
index 0000000..6a50df6
--- /dev/null
+++ b/tests/fixtures/models/model-relationships-with-full-namespace-laravel8.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class Recurrency extends Model
+{
+    use HasFactory;
+
+    /**
+     * The attributes that are mass assignable.
+     *
+     * @var array
+     */
+    protected $fillable = [
+        'user_id',
+        'product_id',
+    ];
+
+    /**
+     * The attributes that should be cast to native types.
+     *
+     * @var array
+     */
+    protected $casts = [
+        'id' => 'integer',
+        'user_id' => 'integer',
+    ];
+
+
+    public function teams()
+    {
+        return $this->belongsToMany(\Some\Package\Team::class);
+    }
+
+    public function orders()
+    {
+        return $this->hasMany(\Other\Package\Order::class);
+    }
+
+    public function duration()
+    {
+        return $this->hasOne(\Other\Package\Duration::class);
+    }
+
+    public function transaction()
+    {
+        return $this->hasOne(\App\MyCustom\Folder\Transaction::class);
+    }
+
+    public function user()
+    {
+        return $this->belongsTo(\App\User::class);
+    }
+
+    public function product()
+    {
+        return $this->belongsTo(\App\Product::class);
+    }
+}