Repository: laravel-shift/blueprint (php)

Issue:
Name-spacing Error in related class imports in factory when using name-spaced model

- Laravel Version: 8.31.0
- PHP Version: 7.4.14
- Blueprint Version: dev-master
- Platform: Mac | Windows | Linux

### Issue:


### draft.yaml:
```yaml
models:

  QuestionFormat:
    format: string

  QuestionType:
    description: string


  Appointment/AppointmentType:
    name: string
    slug: string
    version: integer default:1
    description: text


  Appointment/Reservation:
    first_name: string
    last_name: string
    email: string nullable
    phone: string nullable
    dob: date nullable
    phin: integer nullable
    mb_health_number: integer nullable
    confirmed_at: timestamp nullable

# Appointment is getting to large, thought I'd begin name-spacing it to block development off a bit.
# as a test I tried only this model
  Appointment/ScreeningQuestion:
    appointment_type_id: id foreign
    question_format_id: id foreign
    question_type_id: id foreign
    order: integer default:1
    required: bool default:true
    placeholder: string nullable
    question: string
    hint: string nullable

  Appointment/ScreenQuestionOptions:
    appointment_question_id: id foreign
    key: string
    value: string
    order: integer default:1
```

It actually works quite well it looses track of the name space of the other components. It will create the what appears to be the correct files, however  the namespace will always be the current namespace and not the actual namespace.

```
Directory list
Created:
- database/factories/QuestionFormatFactory.php
- database/factories/QuestionTypeFactory.php
- database/factories/Appointment/AppointmentTypeFactory.php
- database/factories/Appointment/ReservationFactory.php
- database/factories/Appointment/ScreeningQuestionFactory.php
- database/factories/Appointment/ScreeningQuestionOptionsFactory.php
- database/migrations/2021_03_09_173506_create_question_formats_table.php
- database/migrations/2021_03_09_173507_create_question_types_table.php
- database/migrations/2021_03_09_173508_create_appointment_types_table.php
- database/migrations/2021_03_09_173509_create_reservations_table.php
- database/migrations/2021_03_09_173510_create_screening_questions_table.php
- database/migrations/2021_03_09_173511_create_screening_question_options_table.php
- app/Models/QuestionFormat.php
- app/Models/QuestionType.php
- app/Models/Appointment/AppointmentType.php
- app/Models/Appointment/Reservation.php
- app/Models/Appointment/ScreeningQuestion.php
- app/Models/Appointment/ScreeningQuestionOptions.php
```

This file illustrates the issue:
notice the path to the QuestionFormat and QuestionType class that have been left out of the Appointment namespace.
The same thing happens in the model factories.

```php
<?php

namespace App\Models\Appointment;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

/**
 * @property int $id
 * @property int $appointment_type_id
 * @property int $question_format_id
 * @property int $question_type_id
 * @property int $order
 * @property string $required
 * @property string $placeholder
 * @property string $question
 * @property string $hint
 * @property \Carbon\Carbon $created_at
 * @property \Carbon\Carbon $updated_at
 */
class ScreeningQuestion extends Model
{
    use HasFactory;

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'appointment_type_id',
        'question_format_id',
        'question_type_id',
        'order',
        'required',
        'placeholder',
        'question',
        'hint',
    ];

    /**
     * The attributes that should be cast to native types.
     *
     * @var array
     */
    protected $casts = [
        'id' => 'integer',
        'appointment_type_id' => 'integer',
        'question_format_id' => 'integer',
        'question_type_id' => 'integer',
    ];


    /**
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function appointmentType()
    {
        return $this->belongsTo(\App\Models\Appointment\AppointmentType::class);
    }

    /**
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function questionFormat()
    {
        return $this->belongsTo(\App\Models\Appointment\QuestionFormat::class);
    }

    /**
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function questionType()
    {
        return $this->belongsTo(\App\Models\Appointment\QuestionType::class);
    }
}
```

Factory
```php
<?php

namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
use App\Models\Appointment\AppointmentType;
use App\Models\Appointment\QuestionFormat;
use App\Models\Appointment\QuestionType;
use App\Models\Appointment\ScreeningQuestion;

class ScreeningQuestionFactory extends Factory
{
    /**
     * The name of the factory's corresponding model.
     *
     * @var string
     */
    protected $model = ScreeningQuestion::class;

    /**
     * Define the model's default state.
     *
     * @return array
     */
    public function definition()
    {
        return [
            'appointment_type_id' => AppointmentType::factory(),
            'question_format_id' => QuestionFormat::factory(),
            'question_type_id' => QuestionType::factory(),
            'order' => $this->faker->numberBetween(-10000, 10000),
            'required' => $this->faker->word,
            'placeholder' => $this->faker->word,
            'question' => $this->faker->word,
            'hint' => $this->faker->word,
        ];
    }
}

```

Pull request description:
Generate correct reference for nested models
Properly determine namespace for related models in Model and Factory generators. However, there may be other locations still missing...

---
Closes #442

Patch:
diff --git a/src/Generators/FactoryGenerator.php b/src/Generators/FactoryGenerator.php
index eadbd75..2f8219a 100644
--- a/src/Generators/FactoryGenerator.php
+++ b/src/Generators/FactoryGenerator.php
@@ -17,6 +17,9 @@ class FactoryGenerator implements Generator
     /** @var \Illuminate\Contracts\Filesystem\Filesystem */
     private $files;
 
+    /** @var Tree */
+    private $tree;
+
     private $imports = [];
 
     public function __construct($files)
@@ -26,6 +29,8 @@ class FactoryGenerator implements Generator
 
     public function output(Tree $tree): array
     {
+        $this->tree = $tree;
+
         $output = [];
 
         if (Blueprint::isLaravel8OrHigher()) {
@@ -127,9 +132,10 @@ class FactoryGenerator implements Generator
                 }
 
                 $class = Str::studly(Str::singular($table));
+                $reference = $this->fullyQualifyModelReference($class) ?? $model;
 
                 if (Blueprint::isLaravel8OrHigher()) {
-                    $this->addImport($model, $model->fullyQualifiedNamespace() . '\\' . $class);
+                    $this->addImport($model, $reference->fullyQualifiedNamespace() . '\\' . $class);
                 }
                 if ($key === 'id') {
                     if (Blueprint::isLaravel8OrHigher()) {
@@ -138,7 +144,7 @@ class FactoryGenerator implements Generator
                         $definition .= ',' . PHP_EOL;
                     } else {
                         $definition .= str_repeat(self::INDENT, 2) . "'{$column->name()}' => ";
-                        $definition .= sprintf('factory(%s::class)', '\\' . $model->fullyQualifiedNamespace() . '\\' . $class);
+                        $definition .= sprintf('factory(%s::class)', '\\' . $reference->fullyQualifiedNamespace() . '\\' . $class);
                         $definition .= ',' . PHP_EOL;
                     }
                 } else {
@@ -157,14 +163,15 @@ class FactoryGenerator implements Generator
             } elseif ($column->dataType() === 'id' || ($column->dataType() === 'uuid' && Str::endsWith($column->name(), '_id'))) {
                 $name = Str::beforeLast($column->name(), '_id');
                 $class = Str::studly($column->attributes()[0] ?? $name);
+                $reference = $this->fullyQualifyModelReference($class) ?? $model;
 
                 if (Blueprint::isLaravel8OrHigher()) {
-                    $this->addImport($model, $model->fullyQualifiedNamespace() . '\\' . $class);
+                    $this->addImport($model, $reference->fullyQualifiedNamespace() . '\\' . $class);
                     $definition .= str_repeat(self::INDENT, 3) . "'{$column->name()}' => ";
                     $definition .= sprintf('%s::factory()', $class);
                 } else {
                     $definition .= str_repeat(self::INDENT, 2) . "'{$column->name()}' => ";
-                    $definition .= sprintf('factory(%s::class)', '\\' . $model->fullyQualifiedNamespace() . '\\' . $class);
+                    $definition .= sprintf('factory(%s::class)', '\\' . $reference->fullyQualifiedNamespace() . '\\' . $class);
                 }
                 $definition .= ',' . PHP_EOL;
             } elseif (in_array($column->dataType(), ['enum', 'set']) && !empty($column->attributes())) {
@@ -286,4 +293,9 @@ class FactoryGenerator implements Generator
             return !in_array('nullable', $column->modifiers());
         });
     }
+
+    private function fullyQualifyModelReference(string $model_name)
+    {
+        return $this->tree->modelForContext($model_name);
+    }
 }
diff --git a/src/Generators/ModelGenerator.php b/src/Generators/ModelGenerator.php
index 1119d7c..bb64a83 100644
--- a/src/Generators/ModelGenerator.php
+++ b/src/Generators/ModelGenerator.php
@@ -14,6 +14,9 @@ class ModelGenerator implements Generator
     /** @var \Illuminate\Contracts\Filesystem\Filesystem */
     private $files;
 
+    /** @var Tree */
+    private $tree;
+
     public function __construct($files)
     {
         $this->files = $files;
@@ -21,6 +24,8 @@ class ModelGenerator implements Generator
 
     public function output(Tree $tree): array
     {
+        $this->tree = $tree;
+
         $output = [];
 
         if (Blueprint::isLaravel8OrHigher()) {
@@ -210,19 +215,20 @@ class ModelGenerator implements Generator
                 }
 
                 $class_name = Str::studly($class ?? $method_name);
+                $fqcn = $this->fullyQualifyModelReference($class_name) ?? $model->fullyQualifiedNamespace() . '\\' . $class_name;
 
                 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, '\\' . $model->fullyQualifiedNamespace() . '\\' . $class_name, $relation);
-                } elseif (! is_null($key)) {
-                    $relationship = sprintf('$this->%s(%s::class, \'%s\', \'%s\')', $type, '\\' . $model->fullyQualifiedNamespace() . '\\' . $class_name, $column_name, $key);
-                } elseif (! is_null($class) && $type === 'belongsToMany') {
-                    $relationship = sprintf('$this->%s(%s::class, \'%s\')', $type, '\\' . $model->fullyQualifiedNamespace() . '\\' . $class_name, $column_name);
+                    $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);
+                } elseif (!is_null($class) && $type === 'belongsToMany') {
+                    $relationship = sprintf('$this->%s(%s::class, \'%s\')', $type, '\\' . $fqcn, $column_name);
                     $column_name = $class;
                 } else {
-                    $relationship = sprintf('$this->%s(%s::class)', $type, '\\' . $model->fullyQualifiedNamespace() . '\\' . $class_name);
+                    $relationship = sprintf('$this->%s(%s::class)', $type, '\\' . $fqcn);
                 }
 
                 if ($type === 'morphTo') {
@@ -408,4 +414,20 @@ class ModelGenerator implements Generator
 
         return $php_data_types[strtolower($dataType)] ?? 'string';
     }
+
+    private function fullyQualifyModelReference(string $model_name)
+    {
+        // TODO: get model_name from tree.
+        // If not found, assume parallel namespace as controller.
+        // Use respond-statement.php as test case.
+
+        /** @var \Blueprint\Models\Model $model */
+        $model = $this->tree->modelForContext($model_name);
+
+        if (isset($model)) {
+            return $model->fullyQualifiedClassName();
+        }
+
+        return null;
+    }
 }
diff --git a/src/Tree.php b/src/Tree.php
index 522c89b..b4159de 100644
--- a/src/Tree.php
+++ b/src/Tree.php
@@ -46,7 +46,7 @@ class Tree
         });
 
         if (count($matches) === 1) {
-            return $this->models[$matches[0]];
+            return $this->models[current($matches)];
         }
     }
 


Test patch:
diff --git a/tests/Feature/Generators/FactoryGeneratorTest.php b/tests/Feature/Generators/FactoryGeneratorTest.php
index 90e7161..7889d93 100644
--- a/tests/Feature/Generators/FactoryGeneratorTest.php
+++ b/tests/Feature/Generators/FactoryGeneratorTest.php
@@ -169,6 +169,42 @@ class FactoryGeneratorTest extends TestCase
         $this->assertEquals(['created' => ['database/factories/PostFactory.php']], $this->subject->output($tree));
     }
 
+    /**
+     * @test
+     * @environment-setup useLaravel8
+     */
+    public function output_generates_references_for_nested_models()
+    {
+        $this->files->expects('stub')
+            ->with($this->factoryStub)
+            ->andReturn($this->stub($this->factoryStub));
+
+        $this->files->expects('exists')
+            ->times(4)
+            ->andReturnTrue();
+
+        $this->files->expects('put')
+            ->with('database/factories/QuestionTypeFactory.php', \Mockery::type('string'));
+        $this->files->expects('put')
+            ->with('database/factories/Appointment/AppointmentTypeFactory.php', \Mockery::type('string'));
+        $this->files->expects('put')
+            ->with('database/factories/Screening/ReportFactory.php', \Mockery::type('string'));
+        $this->files->expects('put')
+            ->with('database/factories/Screening/ScreeningQuestionFactory.php', $this->fixture('factories/nested-models-laravel8.php'));
+
+        $tokens = $this->blueprint->parse($this->fixture('drafts/nested-models.yaml'));
+        $tree = $this->blueprint->analyze($tokens);
+
+        $this->assertEquals([
+            'created' => [
+                'database/factories/QuestionTypeFactory.php',
+                'database/factories/Appointment/AppointmentTypeFactory.php',
+                'database/factories/Screening/ReportFactory.php',
+                'database/factories/Screening/ScreeningQuestionFactory.php',
+            ],
+        ], $this->subject->output($tree));
+    }
+
     /**
      * @test
      * @environment-setup useLaravel8
diff --git a/tests/Feature/Generators/ModelGeneratorTest.php b/tests/Feature/Generators/ModelGeneratorTest.php
index a0c39b9..33b2805 100644
--- a/tests/Feature/Generators/ModelGeneratorTest.php
+++ b/tests/Feature/Generators/ModelGeneratorTest.php
@@ -581,6 +581,55 @@ class ModelGeneratorTest extends TestCase
         $this->assertEquals(['created' => ['app/Comment.php']], $this->subject->output($tree));
     }
 
+    /**
+     * @test
+     * @environment-setup useLaravel8
+     */
+    public function output_generates_models_with_namespaces_correctly()
+    {
+        $this->app['config']->set('blueprint.models_namespace', 'Models');
+
+        $this->files->expects('stub')
+            ->with($this->modelStub)
+            ->andReturn($this->stub($this->modelStub));
+        $this->files->expects('stub')
+            ->times(4)
+            ->with('model.fillable.stub')
+            ->andReturn($this->stub('model.fillable.stub'));
+        $this->files->expects('stub')
+            ->times(4)
+            ->with('model.casts.stub')
+            ->andReturn($this->stub('model.casts.stub'));
+        $this->files->expects('stub')
+            ->times(4)
+            ->with('model.method.stub')
+            ->andReturn($this->stub('model.method.stub'));
+
+        $this->files->expects('exists')
+            ->times(4)
+            ->andReturnTrue();
+        $this->files->expects('put')
+            ->with('app/Models/QuestionType.php', \Mockery::type('string'));
+        $this->files->expects('put')
+            ->with('app/Models/Appointment/AppointmentType.php', \Mockery::type('string'));
+        $this->files->expects('put')
+            ->with('app/Models/Screening/Report.php', \Mockery::type('string'));
+        $this->files->expects('put')
+            ->with('app/Models/Screening/ScreeningQuestion.php', $this->fixture('models/nested-models-laravel8.php'));
+
+        $tokens = $this->blueprint->parse($this->fixture('drafts/nested-models.yaml'));
+        $tree = $this->blueprint->analyze($tokens);
+
+        $this->assertEquals([
+            'created' => [
+                'app/Models/QuestionType.php',
+                'app/Models/Appointment/AppointmentType.php',
+                'app/Models/Screening/Report.php',
+                'app/Models/Screening/ScreeningQuestion.php',
+            ],
+        ], $this->subject->output($tree));
+    }
+
     /**
      * @test
      * @environment-setup useLaravel8
diff --git a/tests/fixtures/drafts/nested-models.yaml b/tests/fixtures/drafts/nested-models.yaml
new file mode 100644
index 0000000..d13df0d
--- /dev/null
+++ b/tests/fixtures/drafts/nested-models.yaml
@@ -0,0 +1,14 @@
+models:
+  QuestionType:
+    description: string
+
+  Appointment/AppointmentType:
+    name: string
+
+  Screening/Report:
+    name: string
+
+  Screening/ScreeningQuestion:
+    report_id: id
+    appointment_type_id: id
+    question_type_id: id
diff --git a/tests/fixtures/factories/nested-models-laravel8.php b/tests/fixtures/factories/nested-models-laravel8.php
new file mode 100644
index 0000000..453545c
--- /dev/null
+++ b/tests/fixtures/factories/nested-models-laravel8.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Database\Factories;
+
+use Illuminate\Database\Eloquent\Factories\Factory;
+use Illuminate\Support\Str;
+use App\Appointment\AppointmentType;
+use App\QuestionType;
+use App\Screening\Report;
+use App\Screening\ScreeningQuestion;
+
+class ScreeningQuestionFactory extends Factory
+{
+    /**
+     * The name of the factory's corresponding model.
+     *
+     * @var string
+     */
+    protected $model = ScreeningQuestion::class;
+
+    /**
+     * Define the model's default state.
+     *
+     * @return array
+     */
+    public function definition()
+    {
+        return [
+            'report_id' => Report::factory(),
+            'appointment_type_id' => AppointmentType::factory(),
+            'question_type_id' => QuestionType::factory(),
+        ];
+    }
+}
diff --git a/tests/fixtures/models/nested-models-laravel8.php b/tests/fixtures/models/nested-models-laravel8.php
new file mode 100644
index 0000000..147165e
--- /dev/null
+++ b/tests/fixtures/models/nested-models-laravel8.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace App\Models\Screening;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class ScreeningQuestion extends Model
+{
+    use HasFactory;
+
+    /**
+     * The attributes that are mass assignable.
+     *
+     * @var array
+     */
+    protected $fillable = [
+        'report_id',
+        'appointment_type_id',
+        'question_type_id',
+    ];
+
+    /**
+     * The attributes that should be cast to native types.
+     *
+     * @var array
+     */
+    protected $casts = [
+        'id' => 'integer',
+        'report_id' => 'integer',
+        'appointment_type_id' => 'integer',
+        'question_type_id' => 'integer',
+    ];
+
+
+    public function report()
+    {
+        return $this->belongsTo(\App\Models\Screening\Report::class);
+    }
+
+    public function appointmentType()
+    {
+        return $this->belongsTo(\App\Models\Appointment\AppointmentType::class);
+    }
+
+    public function questionType()
+    {
+        return $this->belongsTo(\App\Models\QuestionType::class);
+    }
+}