Repository: Laravel-Backpack/CRUD (php)

Issue:
[Bug] relations field don`t work with resolveRelationUsing method
# Bug report

### What I did
i create a package to add relation to main Video Model with resolveRelationUsing method:

![image](https://github.com/user-attachments/assets/2d8f5efd-6da2-4deb-bc7b-90db40727a55)


this is my field :

![image](https://github.com/user-attachments/assets/c197596d-8ff9-46be-936d-5da0b788c46a)


??

### What I expected to happen

The field works correctly

### What happened
 
![image](https://github.com/user-attachments/assets/197164f6-8104-4434-8791-633948ae28e3)

??

### What I've already tried to fix it

I added `model` or `entity` attribute to filed array but the error still remains.

When you add relation in Video model everything is good

??


### Backpack, Laravel, PHP, DB version

When I run ```php artisan backpack:version``` the output is:

```
### PHP VERSION:
8.3.6

### PHP EXTENSIONS:
Core, bcmath, calendar, ctype, date, filter, hash, iconv, json, SPL, pcre, random, readline, Reflection, session, standard, mysqlnd, tokenizer, zlib, libxml, dom, PDO, openssl, SimpleXML, xml, xmlreader, xmlwriter, curl, ftp, fileinfo, gd, intl, mbstring, exif, mysqli, Phar, pdo_mysql, xsl, zip

### LARAVEL VERSION:
10.48.20.0

### BACKPACK PACKAGE VERSIONS:
backpack/basset: 1.3.5
backpack/crud: 6.7.26
backpack/editable-columns: 3.0.10
backpack/filemanager: 3.0.8
backpack/generators: v4.0.5
backpack/logmanager: v5.0.2
backpack/pro: 2.2.13
backpack/revise-operation: 2.0.0
backpack/theme-coreuiv2: 1.2.5

```


Pull request:
Handle dynamic model relations
## WHY

Fixes #5635 

### BEFORE - What was wrong? What was happening before this PR?

We were not identifying dynamic relations on models. 
Developers could do `Model::resolveRelationUsing('something', fn($item) => $item->belongsTo())` and expect that `CRUD::field('something')` would work as if they were defining any other relationship field for relations that are explicitly defined on the model `public function something() { return $this->belongsTo(); }`

### AFTER - What is happening after this PR?

We can properly identify dynamic relations on models. 🥳 

## HOW

### How did you achieve that, in technical terms?

Changed the relevant functions that identify and work with relations to account for the fact that there might be dynamic relationships defined .

### Is it a breaking change?

I don't think so, no. Tests are green, added more tests to account for this. 


### How can we test the before & after?

Define a dynamic relation on your model, and then try `CRUD::field('relationName')` and it won't work before this PR. 



Patch:
diff --git a/src/app/Library/CrudPanel/Traits/ColumnsProtectedMethods.php b/src/app/Library/CrudPanel/Traits/ColumnsProtectedMethods.php
index aaa8f9c3e..a61f0c69a 100644
--- a/src/app/Library/CrudPanel/Traits/ColumnsProtectedMethods.php
+++ b/src/app/Library/CrudPanel/Traits/ColumnsProtectedMethods.php
@@ -210,7 +210,7 @@ trait ColumnsProtectedMethods
         }
 
         // if there's a method on the model with this name
-        if (method_exists($this->model, $column['name'])) {
+        if (method_exists($this->model, $column['name']) || $this->model->isRelation($column['name'])) {
             // check model method for possibility of being a relationship
             $column['entity'] = $this->modelMethodIsRelationship($this->model, $column['name']);
 
diff --git a/src/app/Library/CrudPanel/Traits/FieldsProtectedMethods.php b/src/app/Library/CrudPanel/Traits/FieldsProtectedMethods.php
index 7cabb2275..fd1e17c9b 100644
--- a/src/app/Library/CrudPanel/Traits/FieldsProtectedMethods.php
+++ b/src/app/Library/CrudPanel/Traits/FieldsProtectedMethods.php
@@ -149,7 +149,7 @@ trait FieldsProtectedMethods
 
         //if the name is dot notation we are sure it's a relationship
         if (strpos($field['name'], '.') !== false) {
-            $possibleMethodName = Str::of($field['name'])->before('.');
+            $possibleMethodName = Str::of($field['name'])->before('.')->value();
             // check model method for possibility of being a relationship
             $field['entity'] = $this->modelMethodIsRelationship($model, $possibleMethodName) ? $field['name'] : false;
 
@@ -157,7 +157,7 @@ trait FieldsProtectedMethods
         }
 
         // if there's a method on the model with this name
-        if (method_exists($model, $field['name'])) {
+        if (method_exists($model, $field['name']) || $model->isRelation($field['name'])) {
             // check model method for possibility of being a relationship
             $field['entity'] = $this->modelMethodIsRelationship($model, $field['name']);
 
diff --git a/src/app/Library/CrudPanel/Traits/Relationships.php b/src/app/Library/CrudPanel/Traits/Relationships.php
index 140f6fcd9..af108929f 100644
--- a/src/app/Library/CrudPanel/Traits/Relationships.php
+++ b/src/app/Library/CrudPanel/Traits/Relationships.php
@@ -19,7 +19,7 @@ trait Relationships
         $possible_method = Str::before($entity, '.');
         $model = isset($field['baseModel']) ? app($field['baseModel']) : $this->model;
 
-        if (method_exists($model, $possible_method)) {
+        if (method_exists($model, $possible_method) || $model->isRelation($possible_method)) {
             $parts = explode('.', $entity);
             // here we are going to iterate through all relation parts to check
             foreach ($parts as $i => $part) {
@@ -271,7 +271,7 @@ trait Relationships
     {
         $relation = $this->getRelationInstance($field);
 
-        if (Str::afterLast($field['name'], '.') === $relation->getRelationName()) {
+        if (Str::afterLast($field['name'], '.') === $relation->getRelationName() || Str::endsWith($relation->getRelationName(), '{closure}')) {
             return $relation->getForeignKeyName();
         }
 
@@ -319,15 +319,16 @@ trait Relationships
      * If the return type extends the Relation class is for sure a relation
      * Otherwise we just assume it's a relation.
      *
-     * DEV NOTE: In future versions we will return `false` when no return type is set and make the return type mandatory for relationships.
-     *           This function should be refactored to only check if $returnType is a subclass of Illuminate\Database\Eloquent\Relations\Relation.
-     *
      * @param  $model
      * @param  $method
      * @return bool|string
      */
     private function modelMethodIsRelationship($model, $method)
     {
+        if (! method_exists($model, $method) && $model->isRelation($method)) {
+            return $method;
+        }
+
         $methodReflection = new \ReflectionMethod($model, $method);
 
         // relationship methods function does not have parameters
diff --git a/src/app/Library/CrudPanel/Traits/Update.php b/src/app/Library/CrudPanel/Traits/Update.php
index bdd3dbbd6..cf894c236 100644
--- a/src/app/Library/CrudPanel/Traits/Update.php
+++ b/src/app/Library/CrudPanel/Traits/Update.php
@@ -110,7 +110,7 @@ trait Update
     {
         [$relatedModel, $relationMethod] = $this->getModelAndMethodFromEntity($model, $field);
 
-        if (! method_exists($relatedModel, $relationMethod)) {
+        if (! method_exists($relatedModel, $relationMethod) && ! $relatedModel->isRelation($relationMethod)) {
             return $relatedModel->{$relationMethod};
         }
 
@@ -157,7 +157,7 @@ trait Update
                 break;
             case 'HasOne':
             case 'MorphOne':
-                if (! method_exists($relatedModel, $relationMethod)) {
+                if (! method_exists($relatedModel, $relationMethod) && ! $relatedModel->isRelation($relationMethod)) {
                     return;
                 }
 


Test patch:
diff --git a/tests/Unit/CrudPanel/CrudPanelColumnsTest.php b/tests/Unit/CrudPanel/CrudPanelColumnsTest.php
index b189a5c45..7baf104e7 100644
--- a/tests/Unit/CrudPanel/CrudPanelColumnsTest.php
+++ b/tests/Unit/CrudPanel/CrudPanelColumnsTest.php
@@ -928,4 +928,21 @@ class CrudPanelColumnsTest extends \Backpack\CRUD\Tests\config\CrudPanel\BaseCru
         $url = $columnArray['wrapper']['href']($this->crudPanel, $columnArray, $this->crudPanel->entry, 1);
         $this->assertEquals('http://localhost/admin/articles/1/show?test=testing&test2=Some%20Content', $url);
     }
+
+    public function testItCanInferFieldAttributesFromADynamicRelation()
+    {
+        User::resolveRelationUsing('dynamicRelation', function ($user) {
+            return $user->belongsTo(\Backpack\CRUD\Tests\config\Models\Bang::class);
+        });
+
+        $this->crudPanel->setModel(User::class);
+        $this->crudPanel->addColumn('dynamicRelation');
+
+        $column = $this->crudPanel->columns()['dynamicRelation'];
+
+        $this->assertEquals('dynamicRelation', $column['name']);
+        $this->assertEquals('name', $column['attribute']);
+        $this->assertEquals('relationship', $column['type']);
+        $this->assertEquals('BelongsTo', $column['relation_type']);
+    }
 }
diff --git a/tests/Unit/CrudPanel/CrudPanelCreateTest.php b/tests/Unit/CrudPanel/CrudPanelCreateTest.php
index b0d8b2edc..813f7fbde 100644
--- a/tests/Unit/CrudPanel/CrudPanelCreateTest.php
+++ b/tests/Unit/CrudPanel/CrudPanelCreateTest.php
@@ -167,6 +167,38 @@ class CrudPanelCreateTest extends \Backpack\CRUD\Tests\config\CrudPanel\BaseDBCr
         $this->assertEquals($account_details->nickname, $account_details_nickname);
     }
 
+    public function testCreateWithOneToOneDynamicRelationship()
+    {
+        User::resolveRelationUsing('dynamicRelation', function ($user) {
+            return $user->hasOne(\Backpack\CRUD\Tests\config\Models\AccountDetails::class);
+        });
+        $this->crudPanel->setModel(User::class);
+        $this->crudPanel->addFields($this->userInputFieldsNoRelationships);
+        $this->crudPanel->addFields([
+            [
+                'name' => 'dynamicRelation.nickname',
+            ],
+            [
+                'name' => 'dynamicRelation.profile_picture',
+            ],
+        ]);
+        $faker = Factory::create();
+        $account_details_nickname = $faker->name;
+        $inputData = [
+            'name' => $faker->name,
+            'email' => $faker->safeEmail,
+            'password' => Hash::make($faker->password()),
+            'dynamicRelation' => [
+                'nickname' => $account_details_nickname,
+                'profile_picture' => 'test.jpg',
+            ],
+        ];
+        $entry = $this->crudPanel->create($inputData);
+        $account_details = $entry->accountDetails()->first();
+
+        $this->assertEquals($account_details->nickname, $account_details_nickname);
+    }
+
     public function testCreateWithOneToOneRelationshipUsingRepeatableInterface()
     {
         $this->crudPanel->setModel(User::class);
@@ -246,6 +278,50 @@ class CrudPanelCreateTest extends \Backpack\CRUD\Tests\config\CrudPanel\BaseDBCr
         $this->assertEquals($article->id, $entry->id);
     }
 
+    public function testCreateWithOneToManyDynamicRelationship()
+    {
+        Article::resolveRelationUsing('dynamicRelation', function ($article) {
+            return $article->belongsTo(\Backpack\CRUD\Tests\config\Models\User::class, 'user_id');
+        });
+
+        $this->crudPanel->setModel(Article::class);
+        $this->crudPanel->addFields([
+            [
+                'name' => 'id',
+                'type' => 'hidden',
+            ], [
+                'name' => 'content',
+            ], [
+                'name' => 'tags',
+            ], [
+                'name' => 'dynamicRelation',
+            ],
+        ]);
+        $faker = Factory::create();
+        $inputData = [
+            'content' => $faker->text(),
+            'tags' => $faker->words(3, true),
+            'dynamicRelation' => 1,
+            'metas' => null,
+            'extras' => null,
+            'cast_metas' => null,
+            'cast_tags' => null,
+            'cast_extras' => null,
+        ];
+
+        $entry = $this->crudPanel->create($inputData);
+
+        unset($inputData['dynamicRelation']);
+        $inputData['user_id'] = 1;
+
+        $userEntry = User::find(1);
+        $article = Article::where('user_id', 1)->with('dynamicRelation')->get()->last();
+        $this->assertEntryEquals($inputData, $entry);
+        $this->assertEquals($article->user_id, $entry->user_id);
+        $this->assertEquals($article->id, $entry->id);
+        $this->assertEquals($article->user_id, $entry->dynamicRelation->id);
+    }
+
     public function testCreateWithManyToManyRelationship()
     {
         $this->crudPanel->setModel(User::class);
@@ -265,6 +341,45 @@ class CrudPanelCreateTest extends \Backpack\CRUD\Tests\config\CrudPanel\BaseDBCr
         $this->assertEntryEquals($inputData, $entry);
     }
 
+    public function testCreateWithManyToManyDynamicRelationship()
+    {
+        User::resolveRelationUsing('dynamicRelation', function ($user) {
+            return $user->belongsToMany(\Backpack\CRUD\Tests\config\Models\Role::class, 'user_role');
+        });
+
+        $this->crudPanel->setModel(User::class);
+        $this->crudPanel->addFields([
+            [
+                'name' => 'id',
+                'type' => 'hidden',
+            ], [
+                'name' => 'name',
+            ], [
+                'name' => 'email',
+                'type' => 'email',
+            ], [
+                'name' => 'password',
+                'type' => 'password',
+            ], [
+                'name' => 'dynamicRelation',
+            ],
+        ]);
+        $faker = Factory::create();
+        $inputData = [
+            'name' => $faker->name,
+            'email' => $faker->safeEmail,
+            'password' => Hash::make($faker->password()),
+            'remember_token' => null,
+            'dynamicRelation' => [1, 2],
+        ];
+
+        $entry = $this->crudPanel->create($inputData);
+
+        $this->assertInstanceOf(User::class, $entry);
+        $this->assertEntryEquals($inputData, $entry);
+        $this->assertCount(2, $entry->dynamicRelation);
+    }
+
     public function testGetRelationFields()
     {
         $this->markTestIncomplete('Not correctly implemented');
@@ -1783,6 +1898,33 @@ class CrudPanelCreateTest extends \Backpack\CRUD\Tests\config\CrudPanel\BaseDBCr
         $this->assertEquals('backpack@laravel.com', User::latest('id')->first()->email);
     }
 
+    public function testItCanCreateDynamicRelationships()
+    {
+        User::resolveRelationUsing('dynamicRelation', function ($user) {
+            return $user->belongsTo(\Backpack\CRUD\Tests\config\Models\Bang::class, 'bang_relation_field');
+        });
+
+        $this->crudPanel->setModel(User::class);
+        $this->crudPanel->addFields($this->userInputFieldsNoRelationships);
+        $this->crudPanel->addField([
+            'name' => 'dynamicRelation',
+        ]);
+
+        $faker = Factory::create();
+
+        $inputData = [
+            'name' => $faker->name,
+            'email' => $faker->safeEmail,
+            'password' => Hash::make($faker->password()),
+            'remember_token' => null,
+            'dynamicRelation' => 1,
+        ];
+
+        $entry = $this->crudPanel->create($inputData);
+
+        $this->assertEquals($entry->dynamicRelation()->first()->name, Bang::find(1)->name);
+    }
+
     private function getPivotInputData(array $pivotRelationData, bool $initCrud = true, bool $allowDuplicates = false)
     {
         $faker = Factory::create();
diff --git a/tests/Unit/CrudPanel/CrudPanelFieldsTest.php b/tests/Unit/CrudPanel/CrudPanelFieldsTest.php
index a8d2835ed..37ca4fd68 100644
--- a/tests/Unit/CrudPanel/CrudPanelFieldsTest.php
+++ b/tests/Unit/CrudPanel/CrudPanelFieldsTest.php
@@ -979,6 +979,28 @@ class CrudPanelFieldsTest extends BaseCrudPanel
         $this->crudPanel->addField('test1, test2');
         $this->assertEquals(['test1', 'test2'], $this->crudPanel->getAllFieldNames());
     }
+
+    public function testItCanInferFieldAttributesFromADynamicRelation()
+    {
+        User::resolveRelationUsing('dynamicRelation', function ($user) {
+            return $user->hasOne(\Backpack\CRUD\Tests\config\Models\AccountDetails::class);
+        });
+
+        $this->crudPanel->setModel(User::class);
+        $this->crudPanel->addField('dynamicRelation.nickname');
+
+        $this->assertEquals([
+            'name' => 'dynamicRelation[nickname]',
+            'type' => 'relationship',
+            'entity' => 'dynamicRelation.nickname',
+            'relation_type' => 'HasOne',
+            'attribute' => 'nickname',
+            'model' => 'Backpack\CRUD\Tests\Config\Models\AccountDetails',
+            'multiple' => false,
+            'pivot' => false,
+            'label' => 'DynamicRelation.nickname',
+        ], $this->crudPanel->fields()['dynamicRelation.nickname']);
+    }
 }
 
 class Invokable
diff --git a/tests/config/Models/User.php b/tests/config/Models/User.php
index 9cf264c9a..bb3ccbc3b 100644
--- a/tests/config/Models/User.php
+++ b/tests/config/Models/User.php
@@ -11,7 +11,7 @@ class User extends Model
 
     protected $table = 'users';
 
-    protected $fillable = ['name', 'email', 'password', 'extras'];
+    protected $fillable = ['name', 'email', 'password', 'extras', 'bang_relation_field'];
 
     public function identifiableAttribute()
     {