Repository: barryvdh/laravel-ide-helper (php)

Issue:
Incorrect PHPDoc being written to model for BelongsTo relation
### Versions:
- ide-helper Version: 2.13.0
- Laravel Version: 10.x
- PHP Version: 8.2.x

### Description:
Hello! 

I'm currently writing the PHPDocs to a few models and I noticed the wrong doc was being generated for the `BelongsTo` relation (and possibly other relations that return single models):

```php
/** @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\User> $createdByUser */
```

However, a `BelongsTo` relation *never* returns a collection so this instead should be:

```php
/** @property-read null|\App\Models\User $createdByUser */
```

This is causing PHPStan Level 2 to fail with:

```
  27     Access to an undefined property Illuminate\Database\Eloquent\Collection<int, App\Models\User>::$name.                                   
```
when trying to call `$model->createdByUser->name`

### Steps To Reproduce:

- Create model with `BelongsTo` relation defined
- Write PHPDocs to model

Thanks!


Pull request description:
Add support for composite keys
Fixes #1474, https://github.com/topclaudy/compoships/issues/77

## Summary
<!-- Please provide an exhaustive description. -->
Hello!

This PR adds support for relationships with composite keys. There have been a couple of issues over the years about this but this still has not been resolved (I don't want to have to extend the Artisan command in my app):

- https://github.com/barryvdh/laravel-ide-helper/issues/838
- https://github.com/barryvdh/laravel-ide-helper/issues/706

There's even an open issue on the Compoship side of things (https://github.com/topclaudy/compoships/issues/77) but as the maintainer stated, there really nothing to do on that end.

I understand Laravel does not support composite keys, however, people do use them and this package already provides the ability to define non-Laravel relations so it would be much easier to support this rather than to expect everyone who uses composite keys to have to extend the Artisan command with the same custom logic.

Thanks!


## Type of change

<!-- Please delete options that are not relevant. -->
- [x] New feature (non-breaking change which adds functionality)

### Checklist
- [x] Existing tests have been adapted and/or new tests have been added
- [x] Add a CHANGELOG.md entry
- [x] Code style has been fixed via `composer fix-style`


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d9dfde6..0a91860 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ All notable changes to this project will be documented in this file.
 ### Added
 - Add support for enum default arguments using enum cases. [#1464 / d8vjork](https://github.com/barryvdh/laravel-ide-helper/pull/1464)
 - Add support for real-time facades in the helper file. [#1455 / filipac](https://github.com/barryvdh/laravel-ide-helper/pull/1455)
+- Add support for relations with composite keys. [#1479 / calebdw](https://github.com/barryvdh/laravel-ide-helper/pull/1479)
 
 2024-02-05, 2.14.0
 ------------------
diff --git a/config/ide-helper.php b/config/ide-helper.php
index 80afdb2..cec91c6 100644
--- a/config/ide-helper.php
+++ b/config/ide-helper.php
@@ -322,7 +322,8 @@ return [
     |
     | When using custom relation types its possible for the class name to not contain
     | the proper return type of the relation. The key of the array is the relationship
-    | method name. The value of the array is the return type of the relation.
+    | method name. The value of the array is the return type of the relation ('many'
+    | or 'morphTo').
     | e.g. `'relationName' => 'many'`.
     |
     */
diff --git a/src/Console/ModelsCommand.php b/src/Console/ModelsCommand.php
index 8759a34..b5cb107 100644
--- a/src/Console/ModelsCommand.php
+++ b/src/Console/ModelsCommand.php
@@ -38,6 +38,7 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
 use Illuminate\Database\Eloquent\Relations\MorphToMany;
 use Illuminate\Database\Eloquent\Relations\Relation;
 use Illuminate\Filesystem\Filesystem;
+use Illuminate\Support\Arr;
 use Illuminate\Support\Collection;
 use Illuminate\Support\Str;
 use phpDocumentor\Reflection\Types\ContextFactory;
@@ -843,12 +844,17 @@ class ModelsCommand extends Command
         $fkProp = $reflectionObj->getProperty('foreignKey');
         $fkProp->setAccessible(true);
 
-        if ($relation === 'belongsTo') {
-            return isset($this->nullableColumns[$fkProp->getValue($relationObj)]) ||
-                !in_array($fkProp->getValue($relationObj), $this->foreignKeyConstraintsColumns, true);
+        foreach (Arr::wrap($fkProp->getValue($relationObj)) as $foreignKey) {
+            if (isset($this->nullableColumns[$foreignKey])) {
+                return true;
+            }
+
+            if (!in_array($foreignKey, $this->foreignKeyConstraintsColumns, true)) {
+                return true;
+            }
         }
 
-        return isset($this->nullableColumns[$fkProp->getValue($relationObj)]);
+        return false;
     }
 
     /**


Test patch:
diff --git a/tests/Console/ModelsCommand/Relations/Models/CompositeBelongsToVariation.php b/tests/Console/ModelsCommand/Relations/Models/CompositeBelongsToVariation.php
new file mode 100644
index 0000000..5fb041d
--- /dev/null
+++ b/tests/Console/ModelsCommand/Relations/Models/CompositeBelongsToVariation.php
@@ -0,0 +1,41 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\Relations\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class CompositeBelongsToVariation extends Model
+{
+    public $table = 'belongs_to_variations';
+
+    public function bothNonNullableWithForeignKeyConstraint(): BelongsTo
+    {
+        // Note, duplicating the keys here for simplicity.
+        return $this->belongsTo(
+            self::class,
+            ['not_null_column_with_foreign_key_constraint', 'not_null_column_with_foreign_key_constraint'],
+            ['not_null_column_with_foreign_key_constraint', 'not_null_column_with_foreign_key_constraint'],
+        );
+    }
+
+    public function nonNullableMixedWithoutForeignKeyConstraint(): BelongsTo
+    {
+        return $this->belongsTo(
+            self::class,
+            ['not_null_column_with_foreign_key_constraint', 'not_null_column_with_no_foreign_key_constraint'],
+            ['not_null_column_with_foreign_key_constraint', 'not_null_column_with_no_foreign_key_constraint'],
+        );
+    }
+
+    public function nullableMixedWithForeignKeyConstraint(): BelongsTo
+    {
+        return $this->belongsTo(
+            self::class,
+            ['nullable_column_with_no_foreign_key_constraint', 'not_null_column_with_foreign_key_constraint'],
+            ['nullable_column_with_no_foreign_key_constraint', 'not_null_column_with_foreign_key_constraint'],
+        );
+    }
+}
diff --git a/tests/Console/ModelsCommand/Relations/Test.php b/tests/Console/ModelsCommand/Relations/Test.php
index fd94d23..3995747 100644
--- a/tests/Console/ModelsCommand/Relations/Test.php
+++ b/tests/Console/ModelsCommand/Relations/Test.php
@@ -6,6 +6,7 @@ namespace Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\Relations;
 
 use Barryvdh\LaravelIdeHelper\Console\ModelsCommand;
 use Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\AbstractModelsCommand;
+use Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\Relations\Types\SampleToAnyMorphedRelationType;
 use Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\Relations\Types\SampleToAnyRelationType;
 use Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\Relations\Types\SampleToManyRelationType;
 use Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\Relations\Types\SampleToOneRelationType;
diff --git a/tests/Console/ModelsCommand/Relations/__snapshots__/Test__test__1.php b/tests/Console/ModelsCommand/Relations/__snapshots__/Test__test__1.php
index 863b180..2412f14 100644
--- a/tests/Console/ModelsCommand/Relations/__snapshots__/Test__test__1.php
+++ b/tests/Console/ModelsCommand/Relations/__snapshots__/Test__test__1.php
@@ -57,6 +57,68 @@ declare(strict_types=1);
 
 namespace Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\Relations\Models;
 
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+/**
+ * Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\Relations\Models\CompositeBelongsToVariation
+ *
+ * @property integer $id
+ * @property integer $not_null_column_with_foreign_key_constraint
+ * @property integer $not_null_column_with_no_foreign_key_constraint
+ * @property integer|null $nullable_column_with_foreign_key_constraint
+ * @property integer|null $nullable_column_with_no_foreign_key_constraint
+ * @property-read CompositeBelongsToVariation $bothNonNullableWithForeignKeyConstraint
+ * @property-read CompositeBelongsToVariation|null $nonNullableMixedWithoutForeignKeyConstraint
+ * @property-read CompositeBelongsToVariation|null $nullableMixedWithForeignKeyConstraint
+ * @method static \Illuminate\Database\Eloquent\Builder|CompositeBelongsToVariation newModelQuery()
+ * @method static \Illuminate\Database\Eloquent\Builder|CompositeBelongsToVariation newQuery()
+ * @method static \Illuminate\Database\Eloquent\Builder|CompositeBelongsToVariation query()
+ * @method static \Illuminate\Database\Eloquent\Builder|CompositeBelongsToVariation whereId($value)
+ * @method static \Illuminate\Database\Eloquent\Builder|CompositeBelongsToVariation whereNotNullColumnWithForeignKeyConstraint($value)
+ * @method static \Illuminate\Database\Eloquent\Builder|CompositeBelongsToVariation whereNotNullColumnWithNoForeignKeyConstraint($value)
+ * @method static \Illuminate\Database\Eloquent\Builder|CompositeBelongsToVariation whereNullableColumnWithForeignKeyConstraint($value)
+ * @method static \Illuminate\Database\Eloquent\Builder|CompositeBelongsToVariation whereNullableColumnWithNoForeignKeyConstraint($value)
+ * @mixin \Eloquent
+ */
+class CompositeBelongsToVariation extends Model
+{
+    public $table = 'belongs_to_variations';
+
+    public function bothNonNullableWithForeignKeyConstraint(): BelongsTo
+    {
+        // Note, duplicating the keys here for simplicity.
+        return $this->belongsTo(
+            self::class,
+            ['not_null_column_with_foreign_key_constraint', 'not_null_column_with_foreign_key_constraint'],
+            ['not_null_column_with_foreign_key_constraint', 'not_null_column_with_foreign_key_constraint'],
+        );
+    }
+
+    public function nonNullableMixedWithoutForeignKeyConstraint(): BelongsTo
+    {
+        return $this->belongsTo(
+            self::class,
+            ['not_null_column_with_foreign_key_constraint', 'not_null_column_with_no_foreign_key_constraint'],
+            ['not_null_column_with_foreign_key_constraint', 'not_null_column_with_no_foreign_key_constraint'],
+        );
+    }
+
+    public function nullableMixedWithForeignKeyConstraint(): BelongsTo
+    {
+        return $this->belongsTo(
+            self::class,
+            ['nullable_column_with_no_foreign_key_constraint', 'not_null_column_with_foreign_key_constraint'],
+            ['nullable_column_with_no_foreign_key_constraint', 'not_null_column_with_foreign_key_constraint'],
+        );
+    }
+}
+<?php
+
+declare(strict_types=1);
+
+namespace Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\Relations\Models;
+
 use Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\Relations\ModelsOtherNamespace\AnotherModel;
 use Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\Relations\Traits\HasTestRelations;
 use Illuminate\Database\Eloquent\Model;