From 9f0d145eed2c49068981a089d79d914871b34dfd Mon Sep 17 00:00:00 2001 From: Arjay Angeles Date: Tue, 18 Nov 2025 13:47:52 +0800 Subject: [PATCH 01/20] Add support for HasManyDeep relationships in EloquentDataTable - Updated composer.json to include staudenmeir/eloquent-has-many-deep package. - Implemented methods in EloquentDataTable to handle HasManyDeep relationships, including foreign key and local key retrieval. - Enhanced the User model to utilize HasManyDeep for comments related to posts. - Added comments relationship to Post model. - Updated TestCase to create comments for posts during database seeding. --- composer.json | 3 +- src/EloquentDataTable.php | 266 ++++++++++++++++++ tests/Integration/HasManyDeepRelationTest.php | 117 ++++++++ tests/Models/Comment.php | 16 ++ tests/Models/Post.php | 5 + tests/Models/User.php | 8 + tests/TestCase.php | 18 +- 7 files changed, 431 insertions(+), 2 deletions(-) create mode 100644 tests/Integration/HasManyDeepRelationTest.php create mode 100644 tests/Models/Comment.php diff --git a/composer.json b/composer.json index 6e034353..ec071851 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,8 @@ "laravel/scout": "^10.8.3", "meilisearch/meilisearch-php": "^1.6.1", "orchestra/testbench": "^10", - "rector/rector": "^2.0" + "rector/rector": "^2.0", + "staudenmeir/eloquent-has-many-deep": "^1.21" }, "suggest": { "yajra/laravel-datatables-export": "Plugin for server-side exporting using livewire and queue worker.", diff --git a/src/EloquentDataTable.php b/src/EloquentDataTable.php index a7783d57..9c30b36a 100644 --- a/src/EloquentDataTable.php +++ b/src/EloquentDataTable.php @@ -160,6 +160,225 @@ protected function isMorphRelation($relation) return $isMorph; } + /** + * Check if a relation is a HasManyDeep relationship. + * + * @param Relation $model + * @return bool + */ + protected function isHasManyDeep($model): bool + { + return class_exists('Staudenmeir\EloquentHasManyDeep\HasManyDeep') + && $model instanceof \Staudenmeir\EloquentHasManyDeep\HasManyDeep; + } + + /** + * Get the foreign key name for a HasManyDeep relationship. + * This is the foreign key on the final related table that points to the intermediate table. + * + * @param Relation $model + * @return string + */ + protected function getHasManyDeepForeignKey($model): string + { + // Try to get from relationship definition using reflection + try { + $reflection = new \ReflectionClass($model); + if ($reflection->hasProperty('foreignKeys')) { + $property = $reflection->getProperty('foreignKeys'); + $property->setAccessible(true); + $foreignKeys = $property->getValue($model); + + if (is_array($foreignKeys) && !empty($foreignKeys)) { + // Get the last foreign key (for the final join) + $lastFK = end($foreignKeys); + if (is_string($lastFK) && str_contains($lastFK, '.')) { + $parts = explode('.', $lastFK); + return end($parts); + } + return $lastFK; + } + } + } catch (\Exception $e) { + // Fallback + } + + // Try to get the foreign key using common HasManyDeep methods + if (method_exists($model, 'getForeignKeyName')) { + return $model->getForeignKeyName(); + } + + // HasManyDeep may use getQualifiedForeignKeyName() and extract the column + if (method_exists($model, 'getQualifiedForeignKeyName')) { + $qualified = $model->getQualifiedForeignKeyName(); + $parts = explode('.', $qualified); + return end($parts); + } + + // Fallback: try to infer from intermediate model + $intermediateTable = $this->getHasManyDeepIntermediateTable($model, ''); + if ($intermediateTable) { + // Assume the related table has a foreign key named {intermediate_table}_id + return $intermediateTable.'_id'; + } + + // Final fallback: use the related model's key name + return $model->getRelated()->getKeyName(); + } + + /** + * Get the local key name for a HasManyDeep relationship. + * This is the local key on the intermediate table (or parent if no intermediate). + * + * @param Relation $model + * @return string + */ + protected function getHasManyDeepLocalKey($model): string + { + // Try to get from relationship definition using reflection + try { + $reflection = new \ReflectionClass($model); + if ($reflection->hasProperty('localKeys')) { + $property = $reflection->getProperty('localKeys'); + $property->setAccessible(true); + $localKeys = $property->getValue($model); + + if (is_array($localKeys) && !empty($localKeys)) { + // Get the last local key (for the final join) + $lastLK = end($localKeys); + if (is_string($lastLK) && str_contains($lastLK, '.')) { + $parts = explode('.', $lastLK); + return end($parts); + } + return $lastLK; + } + } + } catch (\Exception $e) { + // Fallback + } + + // Try to get the local key using common HasManyDeep methods + if (method_exists($model, 'getLocalKeyName')) { + return $model->getLocalKeyName(); + } + + // HasManyDeep may use getQualifiedLocalKeyName() and extract the column + if (method_exists($model, 'getQualifiedLocalKeyName')) { + $qualified = $model->getQualifiedLocalKeyName(); + $parts = explode('.', $qualified); + return end($parts); + } + + // Fallback: use the intermediate model's key name, or parent if no intermediate + $intermediateTable = $this->getHasManyDeepIntermediateTable($model, ''); + if ($intermediateTable) { + try { + $reflection = new \ReflectionClass($model); + if ($reflection->hasProperty('through')) { + $property = $reflection->getProperty('through'); + $property->setAccessible(true); + $through = $property->getValue($model); + if (is_array($through) && !empty($through)) { + $firstThrough = is_string($through[0]) ? $through[0] : get_class($through[0]); + if (class_exists($firstThrough)) { + $throughModel = new $firstThrough; + return $throughModel->getKeyName(); + } + } + } + } catch (\Exception $e) { + // Fallback + } + } + + // Final fallback: use the parent model's key name + return $model->getParent()->getKeyName(); + } + + /** + * Get the intermediate table name for a HasManyDeep relationship. + * + * @param Relation $model + * @param string $lastAlias + * @return string|null + */ + protected function getHasManyDeepIntermediateTable($model, $lastAlias): ?string + { + // Try to get intermediate models from the relationship + // HasManyDeep stores intermediate models in a protected property + try { + $reflection = new \ReflectionClass($model); + if ($reflection->hasProperty('through')) { + $property = $reflection->getProperty('through'); + $property->setAccessible(true); + $through = $property->getValue($model); + + if (is_array($through) && !empty($through)) { + // Get the first intermediate model + $firstThrough = is_string($through[0]) ? $through[0] : get_class($through[0]); + if (class_exists($firstThrough)) { + $throughModel = new $firstThrough; + return $throughModel->getTable(); + } + } + } + } catch (\Exception $e) { + // Fallback if reflection fails + } + + return null; + } + + /** + * Get the foreign key for joining to the intermediate table. + * + * @param Relation $model + * @return string + */ + protected function getHasManyDeepIntermediateForeignKey($model): string + { + // The foreign key on the intermediate table that points to the parent + // For User -> Posts -> Comments, this would be posts.user_id + $parent = $model->getParent(); + $parentKey = $parent->getKeyName(); + + // Try to get from relationship definition + try { + $reflection = new \ReflectionClass($model); + if ($reflection->hasProperty('foreignKeys')) { + $property = $reflection->getProperty('foreignKeys'); + $property->setAccessible(true); + $foreignKeys = $property->getValue($model); + + if (is_array($foreignKeys) && !empty($foreignKeys)) { + $firstFK = $foreignKeys[0]; + if (is_string($firstFK) && str_contains($firstFK, '.')) { + $parts = explode('.', $firstFK); + return end($parts); + } + return $firstFK; + } + } + } catch (\Exception $e) { + // Fallback + } + + // Default: assume intermediate table has a foreign key named {parent_table}_id + return $parent->getTable().'_id'; + } + + /** + * Get the local key for joining from the parent to the intermediate table. + * + * @param Relation $model + * @return string + */ + protected function getHasManyDeepIntermediateLocalKey($model): string + { + // The local key on the parent table + return $model->getParent()->getKeyName(); + } + /** * {@inheritDoc} * @@ -269,6 +488,53 @@ protected function joinEagerLoadedColumn($relation, $relationColumn) $other = $tableAlias.'.'.$model->getOwnerKeyName(); break; + case $this->isHasManyDeep($model): + // HasManyDeep relationships can traverse multiple intermediate models + // We need to join through all intermediate models to reach the final related table + $related = $model->getRelated(); + + // Get the qualified parent key to determine the first intermediate model + $qualifiedParentKey = $model->getQualifiedParentKeyName(); + $parentTable = explode('.', $qualifiedParentKey)[0]; + + // For HasManyDeep, we need to join through intermediate models + // The relationship query already knows the structure, so we'll use it + // First, join to the first intermediate model (if not already joined) + $intermediateTable = $this->getHasManyDeepIntermediateTable($model, $lastAlias); + + if ($intermediateTable && $intermediateTable !== $lastAlias) { + // Join to intermediate table first + if ($this->enableEagerJoinAliases) { + $intermediateAlias = $tableAlias.'_intermediate'; + $intermediate = $intermediateTable.' as '.$intermediateAlias; + } else { + $intermediateAlias = $intermediateTable; + $intermediate = $intermediateTable; + } + + $intermediateFK = $this->getHasManyDeepIntermediateForeignKey($model); + $intermediateLocal = $this->getHasManyDeepIntermediateLocalKey($model); + $this->performJoin($intermediate, $intermediateAlias.'.'.$intermediateFK, ltrim($lastAlias.'.'.$intermediateLocal, '.')); + $lastAlias = $intermediateAlias; + } + + // Now join to the final related table + if ($this->enableEagerJoinAliases) { + $table = $related->getTable().' as '.$tableAlias; + } else { + $table = $tableAlias = $related->getTable(); + } + + // Get the foreign key on the related table (points to intermediate) + $foreignKey = $this->getHasManyDeepForeignKey($model); + $localKey = $this->getHasManyDeepLocalKey($model); + + $foreign = $tableAlias.'.'.$foreignKey; + $other = ltrim($lastAlias.'.'.$localKey, '.'); + + $lastQuery->addSelect($tableAlias.'.'.$relationColumn); + break; + default: throw new Exception('Relation '.$model::class.' is not yet supported.'); } diff --git a/tests/Integration/HasManyDeepRelationTest.php b/tests/Integration/HasManyDeepRelationTest.php new file mode 100644 index 00000000..8e48e4c8 --- /dev/null +++ b/tests/Integration/HasManyDeepRelationTest.php @@ -0,0 +1,117 @@ +call('GET', '/relations/hasManyDeep'); + $response->assertJson([ + 'draw' => 0, + 'recordsTotal' => 20, + 'recordsFiltered' => 20, + ]); + + $this->assertCount(20, $response->json()['data']); + } + + #[Test] + public function it_can_search_has_many_deep_relation() + { + $response = $this->call('GET', '/relations/hasManyDeepSearchRelation', [ + 'columns' => [ + [ + 'data' => 'comments.content', + 'searchable' => true, + 'search' => [ + 'value' => 'Comment-1', + ], + ], + ], + ]); + + // HasManyDeep can return multiple rows per user (one per comment) + // So we expect at least some results, but the exact count depends on the join + $response->assertJson([ + 'draw' => 0, + 'recordsTotal' => 20, + ]); + + $this->assertGreaterThanOrEqual(20, $response->json()['recordsFiltered']); + $this->assertGreaterThanOrEqual(20, count($response->json()['data'])); + } + + #[Test] + public function it_can_perform_global_search_on_the_relation() + { + $response = $this->getJsonResponse([ + 'search' => ['value' => 'Comment-1'], + ]); + + $response->assertJson([ + 'draw' => 0, + 'recordsTotal' => 20, + 'recordsFiltered' => 20, + ]); + + $this->assertCount(20, $response->json()['data']); + } + + #[Test] + public function it_can_order_by_has_many_deep_relation_column() + { + $response = $this->call('GET', '/relations/hasManyDeep', [ + 'columns' => [ + ['data' => 'comments.content', 'name' => 'comments.content', 'searchable' => true, 'orderable' => true], + ['data' => 'name', 'name' => 'name', 'searchable' => true, 'orderable' => true], + ], + 'order' => [ + [ + 'column' => 0, + 'dir' => 'asc', + ], + ], + ]); + + // HasManyDeep can return multiple rows per user when ordering by related column + $response->assertJson([ + 'draw' => 0, + 'recordsTotal' => 20, + ]); + + $this->assertGreaterThanOrEqual(20, $response->json()['recordsFiltered']); + $this->assertGreaterThanOrEqual(20, count($response->json()['data'])); + } + + protected function getJsonResponse(array $params = []) + { + $data = [ + 'columns' => [ + ['data' => 'name', 'name' => 'name', 'searchable' => true, 'orderable' => true], + ['data' => 'comments.content', 'name' => 'comments.content', 'searchable' => true, 'orderable' => true], + ], + ]; + + return $this->call('GET', '/relations/hasManyDeep', array_merge($data, $params)); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->app['router']->get('/relations/hasManyDeep', fn (DataTables $datatables) => $datatables->eloquent(User::with('comments')->select('users.*'))->toJson()); + + $this->app['router']->get('/relations/hasManyDeepSearchRelation', fn (DataTables $datatables) => $datatables->eloquent(User::with('comments'))->toJson()); + } +} + diff --git a/tests/Models/Comment.php b/tests/Models/Comment.php new file mode 100644 index 00000000..ce0c846d --- /dev/null +++ b/tests/Models/Comment.php @@ -0,0 +1,16 @@ +belongsTo(Post::class); + } +} + diff --git a/tests/Models/Post.php b/tests/Models/Post.php index f9b8e34d..fd43c3ac 100644 --- a/tests/Models/Post.php +++ b/tests/Models/Post.php @@ -22,4 +22,9 @@ public function heart() { return $this->hasOneThrough(Heart::class, User::class, 'id', 'user_id', 'user_id', 'id'); } + + public function comments() + { + return $this->hasMany(Comment::class); + } } diff --git a/tests/Models/User.php b/tests/Models/User.php index effc2bfb..aa847e63 100644 --- a/tests/Models/User.php +++ b/tests/Models/User.php @@ -3,9 +3,12 @@ namespace Yajra\DataTables\Tests\Models; use Illuminate\Database\Eloquent\Model; +use Staudenmeir\EloquentHasManyDeep\HasRelationships; class User extends Model { + use HasRelationships; + protected $guarded = []; public function posts() @@ -28,6 +31,11 @@ public function user() return $this->morphTo(); } + public function comments() + { + return $this->hasManyDeep(Comment::class, [Post::class]); + } + public function getColorAttribute() { return $this->color ?? '#000000'; diff --git a/tests/TestCase.php b/tests/TestCase.php index eeda4d70..b546dbff 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -5,6 +5,7 @@ use Illuminate\Database\Schema\Blueprint; use Orchestra\Testbench\TestCase as BaseTestCase; use Yajra\DataTables\Tests\Models\AnimalUser; +use Yajra\DataTables\Tests\Models\Comment; use Yajra\DataTables\Tests\Models\HumanUser; use Yajra\DataTables\Tests\Models\Role; use Yajra\DataTables\Tests\Models\User; @@ -83,6 +84,14 @@ protected function migrateDatabase() $table->softDeletes(); }); } + if (! $schemaBuilder->hasTable('comments')) { + $schemaBuilder->create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('post_id'); + $table->string('content'); + $table->timestamps(); + }); + } } protected function seedDatabase() @@ -100,9 +109,16 @@ protected function seedDatabase() ]); collect(range(1, 3))->each(function ($i) use ($user) { - $user->posts()->create([ + $post = $user->posts()->create([ 'title' => "User-{$user->id} Post-{$i}", ]); + + // Create comments for each post + collect(range(1, 2))->each(function ($j) use ($post) { + $post->comments()->create([ + 'content' => "Comment-{$j} for Post-{$post->id}", + ]); + }); }); $user->heart()->create([ From 56ef6196c1797b48bd78d33d456ce18953048013 Mon Sep 17 00:00:00 2001 From: yajra <2687997+yajra@users.noreply.github.com> Date: Tue, 18 Nov 2025 05:49:58 +0000 Subject: [PATCH 02/20] fix: pint :robot: --- src/EloquentDataTable.php | 52 ++++++++++--------- tests/Integration/HasManyDeepRelationTest.php | 1 - tests/Models/Comment.php | 1 - tests/TestCase.php | 1 - 4 files changed, 28 insertions(+), 27 deletions(-) diff --git a/src/EloquentDataTable.php b/src/EloquentDataTable.php index 9c30b36a..a3e9fd4f 100644 --- a/src/EloquentDataTable.php +++ b/src/EloquentDataTable.php @@ -164,7 +164,6 @@ protected function isMorphRelation($relation) * Check if a relation is a HasManyDeep relationship. * * @param Relation $model - * @return bool */ protected function isHasManyDeep($model): bool { @@ -177,7 +176,6 @@ protected function isHasManyDeep($model): bool * This is the foreign key on the final related table that points to the intermediate table. * * @param Relation $model - * @return string */ protected function getHasManyDeepForeignKey($model): string { @@ -188,14 +186,16 @@ protected function getHasManyDeepForeignKey($model): string $property = $reflection->getProperty('foreignKeys'); $property->setAccessible(true); $foreignKeys = $property->getValue($model); - - if (is_array($foreignKeys) && !empty($foreignKeys)) { + + if (is_array($foreignKeys) && ! empty($foreignKeys)) { // Get the last foreign key (for the final join) $lastFK = end($foreignKeys); if (is_string($lastFK) && str_contains($lastFK, '.')) { $parts = explode('.', $lastFK); + return end($parts); } + return $lastFK; } } @@ -212,6 +212,7 @@ protected function getHasManyDeepForeignKey($model): string if (method_exists($model, 'getQualifiedForeignKeyName')) { $qualified = $model->getQualifiedForeignKeyName(); $parts = explode('.', $qualified); + return end($parts); } @@ -231,7 +232,6 @@ protected function getHasManyDeepForeignKey($model): string * This is the local key on the intermediate table (or parent if no intermediate). * * @param Relation $model - * @return string */ protected function getHasManyDeepLocalKey($model): string { @@ -242,14 +242,16 @@ protected function getHasManyDeepLocalKey($model): string $property = $reflection->getProperty('localKeys'); $property->setAccessible(true); $localKeys = $property->getValue($model); - - if (is_array($localKeys) && !empty($localKeys)) { + + if (is_array($localKeys) && ! empty($localKeys)) { // Get the last local key (for the final join) $lastLK = end($localKeys); if (is_string($lastLK) && str_contains($lastLK, '.')) { $parts = explode('.', $lastLK); + return end($parts); } + return $lastLK; } } @@ -266,6 +268,7 @@ protected function getHasManyDeepLocalKey($model): string if (method_exists($model, 'getQualifiedLocalKeyName')) { $qualified = $model->getQualifiedLocalKeyName(); $parts = explode('.', $qualified); + return end($parts); } @@ -278,10 +281,11 @@ protected function getHasManyDeepLocalKey($model): string $property = $reflection->getProperty('through'); $property->setAccessible(true); $through = $property->getValue($model); - if (is_array($through) && !empty($through)) { + if (is_array($through) && ! empty($through)) { $firstThrough = is_string($through[0]) ? $through[0] : get_class($through[0]); if (class_exists($firstThrough)) { $throughModel = new $firstThrough; + return $throughModel->getKeyName(); } } @@ -300,7 +304,6 @@ protected function getHasManyDeepLocalKey($model): string * * @param Relation $model * @param string $lastAlias - * @return string|null */ protected function getHasManyDeepIntermediateTable($model, $lastAlias): ?string { @@ -312,12 +315,13 @@ protected function getHasManyDeepIntermediateTable($model, $lastAlias): ?string $property = $reflection->getProperty('through'); $property->setAccessible(true); $through = $property->getValue($model); - - if (is_array($through) && !empty($through)) { + + if (is_array($through) && ! empty($through)) { // Get the first intermediate model $firstThrough = is_string($through[0]) ? $through[0] : get_class($through[0]); if (class_exists($firstThrough)) { $throughModel = new $firstThrough; + return $throughModel->getTable(); } } @@ -333,7 +337,6 @@ protected function getHasManyDeepIntermediateTable($model, $lastAlias): ?string * Get the foreign key for joining to the intermediate table. * * @param Relation $model - * @return string */ protected function getHasManyDeepIntermediateForeignKey($model): string { @@ -341,7 +344,7 @@ protected function getHasManyDeepIntermediateForeignKey($model): string // For User -> Posts -> Comments, this would be posts.user_id $parent = $model->getParent(); $parentKey = $parent->getKeyName(); - + // Try to get from relationship definition try { $reflection = new \ReflectionClass($model); @@ -349,13 +352,15 @@ protected function getHasManyDeepIntermediateForeignKey($model): string $property = $reflection->getProperty('foreignKeys'); $property->setAccessible(true); $foreignKeys = $property->getValue($model); - - if (is_array($foreignKeys) && !empty($foreignKeys)) { + + if (is_array($foreignKeys) && ! empty($foreignKeys)) { $firstFK = $foreignKeys[0]; if (is_string($firstFK) && str_contains($firstFK, '.')) { $parts = explode('.', $firstFK); + return end($parts); } + return $firstFK; } } @@ -371,7 +376,6 @@ protected function getHasManyDeepIntermediateForeignKey($model): string * Get the local key for joining from the parent to the intermediate table. * * @param Relation $model - * @return string */ protected function getHasManyDeepIntermediateLocalKey($model): string { @@ -492,16 +496,16 @@ protected function joinEagerLoadedColumn($relation, $relationColumn) // HasManyDeep relationships can traverse multiple intermediate models // We need to join through all intermediate models to reach the final related table $related = $model->getRelated(); - + // Get the qualified parent key to determine the first intermediate model $qualifiedParentKey = $model->getQualifiedParentKeyName(); $parentTable = explode('.', $qualifiedParentKey)[0]; - + // For HasManyDeep, we need to join through intermediate models // The relationship query already knows the structure, so we'll use it // First, join to the first intermediate model (if not already joined) $intermediateTable = $this->getHasManyDeepIntermediateTable($model, $lastAlias); - + if ($intermediateTable && $intermediateTable !== $lastAlias) { // Join to intermediate table first if ($this->enableEagerJoinAliases) { @@ -511,27 +515,27 @@ protected function joinEagerLoadedColumn($relation, $relationColumn) $intermediateAlias = $intermediateTable; $intermediate = $intermediateTable; } - + $intermediateFK = $this->getHasManyDeepIntermediateForeignKey($model); $intermediateLocal = $this->getHasManyDeepIntermediateLocalKey($model); $this->performJoin($intermediate, $intermediateAlias.'.'.$intermediateFK, ltrim($lastAlias.'.'.$intermediateLocal, '.')); $lastAlias = $intermediateAlias; } - + // Now join to the final related table if ($this->enableEagerJoinAliases) { $table = $related->getTable().' as '.$tableAlias; } else { $table = $tableAlias = $related->getTable(); } - + // Get the foreign key on the related table (points to intermediate) $foreignKey = $this->getHasManyDeepForeignKey($model); $localKey = $this->getHasManyDeepLocalKey($model); - + $foreign = $tableAlias.'.'.$foreignKey; $other = ltrim($lastAlias.'.'.$localKey, '.'); - + $lastQuery->addSelect($tableAlias.'.'.$relationColumn); break; diff --git a/tests/Integration/HasManyDeepRelationTest.php b/tests/Integration/HasManyDeepRelationTest.php index 8e48e4c8..49e71f87 100644 --- a/tests/Integration/HasManyDeepRelationTest.php +++ b/tests/Integration/HasManyDeepRelationTest.php @@ -114,4 +114,3 @@ protected function setUp(): void $this->app['router']->get('/relations/hasManyDeepSearchRelation', fn (DataTables $datatables) => $datatables->eloquent(User::with('comments'))->toJson()); } } - diff --git a/tests/Models/Comment.php b/tests/Models/Comment.php index ce0c846d..acf12240 100644 --- a/tests/Models/Comment.php +++ b/tests/Models/Comment.php @@ -13,4 +13,3 @@ public function post() return $this->belongsTo(Post::class); } } - diff --git a/tests/TestCase.php b/tests/TestCase.php index b546dbff..9cc3473a 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -5,7 +5,6 @@ use Illuminate\Database\Schema\Blueprint; use Orchestra\Testbench\TestCase as BaseTestCase; use Yajra\DataTables\Tests\Models\AnimalUser; -use Yajra\DataTables\Tests\Models\Comment; use Yajra\DataTables\Tests\Models\HumanUser; use Yajra\DataTables\Tests\Models\Role; use Yajra\DataTables\Tests\Models\User; From f417853a1e1dfb2513ea7156d1970e715664161b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Nov 2025 06:00:08 +0000 Subject: [PATCH 03/20] Initial plan From de2cb6dd979bbdd0d712a6a457a97906fd1c9ae7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Nov 2025 06:08:55 +0000 Subject: [PATCH 04/20] Address code review feedback for HasManyDeep implementation Co-authored-by: yajra <2687997+yajra@users.noreply.github.com> --- composer.json | 6 +- src/EloquentDataTable.php | 212 ++++++++++-------- tests/Integration/HasManyDeepRelationTest.php | 7 +- 3 files changed, 121 insertions(+), 104 deletions(-) diff --git a/composer.json b/composer.json index ec071851..ff0d322d 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,8 @@ "illuminate/filesystem": "^12", "illuminate/http": "^12", "illuminate/support": "^12", - "illuminate/view": "^12" + "illuminate/view": "^12", + "staudenmeir/eloquent-has-many-deep": "^1.21" }, "require-dev": { "algolia/algoliasearch-client-php": "^3.4.1", @@ -29,8 +30,7 @@ "laravel/scout": "^10.8.3", "meilisearch/meilisearch-php": "^1.6.1", "orchestra/testbench": "^10", - "rector/rector": "^2.0", - "staudenmeir/eloquent-has-many-deep": "^1.21" + "rector/rector": "^2.0" }, "suggest": { "yajra/laravel-datatables-export": "Plugin for server-side exporting using livewire and queue worker.", diff --git a/src/EloquentDataTable.php b/src/EloquentDataTable.php index a3e9fd4f..86562393 100644 --- a/src/EloquentDataTable.php +++ b/src/EloquentDataTable.php @@ -163,7 +163,7 @@ protected function isMorphRelation($relation) /** * Check if a relation is a HasManyDeep relationship. * - * @param Relation $model + * @param \Illuminate\Database\Eloquent\Relations\Relation $model */ protected function isHasManyDeep($model): bool { @@ -175,32 +175,17 @@ protected function isHasManyDeep($model): bool * Get the foreign key name for a HasManyDeep relationship. * This is the foreign key on the final related table that points to the intermediate table. * - * @param Relation $model + * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep $model */ protected function getHasManyDeepForeignKey($model): string { // Try to get from relationship definition using reflection - try { - $reflection = new \ReflectionClass($model); - if ($reflection->hasProperty('foreignKeys')) { - $property = $reflection->getProperty('foreignKeys'); - $property->setAccessible(true); - $foreignKeys = $property->getValue($model); + $foreignKeys = $this->getForeignKeys($model); + if (! empty($foreignKeys)) { + // Get the last foreign key (for the final join) + $lastFK = end($foreignKeys); - if (is_array($foreignKeys) && ! empty($foreignKeys)) { - // Get the last foreign key (for the final join) - $lastFK = end($foreignKeys); - if (is_string($lastFK) && str_contains($lastFK, '.')) { - $parts = explode('.', $lastFK); - - return end($parts); - } - - return $lastFK; - } - } - } catch (\Exception $e) { - // Fallback + return $this->extractColumnFromQualified($lastFK); } // Try to get the foreign key using common HasManyDeep methods @@ -211,16 +196,15 @@ protected function getHasManyDeepForeignKey($model): string // HasManyDeep may use getQualifiedForeignKeyName() and extract the column if (method_exists($model, 'getQualifiedForeignKeyName')) { $qualified = $model->getQualifiedForeignKeyName(); - $parts = explode('.', $qualified); - return end($parts); + return $this->extractColumnFromQualified($qualified); } // Fallback: try to infer from intermediate model $intermediateTable = $this->getHasManyDeepIntermediateTable($model, ''); if ($intermediateTable) { // Assume the related table has a foreign key named {intermediate_table}_id - return $intermediateTable.'_id'; + return \Illuminate\Support\Str::singular($intermediateTable).'_id'; } // Final fallback: use the related model's key name @@ -231,32 +215,29 @@ protected function getHasManyDeepForeignKey($model): string * Get the local key name for a HasManyDeep relationship. * This is the local key on the intermediate table (or parent if no intermediate). * - * @param Relation $model + * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep $model */ protected function getHasManyDeepLocalKey($model): string { // Try to get from relationship definition using reflection + $localKeys = []; try { $reflection = new \ReflectionClass($model); if ($reflection->hasProperty('localKeys')) { $property = $reflection->getProperty('localKeys'); $property->setAccessible(true); $localKeys = $property->getValue($model); - - if (is_array($localKeys) && ! empty($localKeys)) { - // Get the last local key (for the final join) - $lastLK = end($localKeys); - if (is_string($lastLK) && str_contains($lastLK, '.')) { - $parts = explode('.', $lastLK); - - return end($parts); - } - - return $lastLK; - } } } catch (\Exception $e) { - // Fallback + // Reflection failed - proceed to other methods + // This is safe because we have multiple fallback strategies + } + + if (is_array($localKeys) && ! empty($localKeys)) { + // Get the last local key (for the final join) + $lastLK = end($localKeys); + + return $this->extractColumnFromQualified($lastLK); } // Try to get the local key using common HasManyDeep methods @@ -267,31 +248,21 @@ protected function getHasManyDeepLocalKey($model): string // HasManyDeep may use getQualifiedLocalKeyName() and extract the column if (method_exists($model, 'getQualifiedLocalKeyName')) { $qualified = $model->getQualifiedLocalKeyName(); - $parts = explode('.', $qualified); - return end($parts); + return $this->extractColumnFromQualified($qualified); } // Fallback: use the intermediate model's key name, or parent if no intermediate $intermediateTable = $this->getHasManyDeepIntermediateTable($model, ''); if ($intermediateTable) { - try { - $reflection = new \ReflectionClass($model); - if ($reflection->hasProperty('through')) { - $property = $reflection->getProperty('through'); - $property->setAccessible(true); - $through = $property->getValue($model); - if (is_array($through) && ! empty($through)) { - $firstThrough = is_string($through[0]) ? $through[0] : get_class($through[0]); - if (class_exists($firstThrough)) { - $throughModel = new $firstThrough; - - return $throughModel->getKeyName(); - } - } + $through = $this->getThroughModels($model); + if (! empty($through)) { + $firstThrough = is_string($through[0]) ? $through[0] : get_class($through[0]); + if (class_exists($firstThrough)) { + $throughModel = app($firstThrough); + + return $throughModel->getKeyName(); } - } catch (\Exception $e) { - // Fallback } } @@ -302,32 +273,22 @@ protected function getHasManyDeepLocalKey($model): string /** * Get the intermediate table name for a HasManyDeep relationship. * - * @param Relation $model + * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep $model * @param string $lastAlias */ protected function getHasManyDeepIntermediateTable($model, $lastAlias): ?string { // Try to get intermediate models from the relationship // HasManyDeep stores intermediate models in a protected property - try { - $reflection = new \ReflectionClass($model); - if ($reflection->hasProperty('through')) { - $property = $reflection->getProperty('through'); - $property->setAccessible(true); - $through = $property->getValue($model); - - if (is_array($through) && ! empty($through)) { - // Get the first intermediate model - $firstThrough = is_string($through[0]) ? $through[0] : get_class($through[0]); - if (class_exists($firstThrough)) { - $throughModel = new $firstThrough; - - return $throughModel->getTable(); - } - } + $through = $this->getThroughModels($model); + if (! empty($through)) { + // Get the first intermediate model + $firstThrough = is_string($through[0]) ? $through[0] : get_class($through[0]); + if (class_exists($firstThrough)) { + $throughModel = app($firstThrough); + + return $throughModel->getTable(); } - } catch (\Exception $e) { - // Fallback if reflection fails } return null; @@ -336,46 +297,30 @@ protected function getHasManyDeepIntermediateTable($model, $lastAlias): ?string /** * Get the foreign key for joining to the intermediate table. * - * @param Relation $model + * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep $model */ protected function getHasManyDeepIntermediateForeignKey($model): string { // The foreign key on the intermediate table that points to the parent // For User -> Posts -> Comments, this would be posts.user_id $parent = $model->getParent(); - $parentKey = $parent->getKeyName(); // Try to get from relationship definition - try { - $reflection = new \ReflectionClass($model); - if ($reflection->hasProperty('foreignKeys')) { - $property = $reflection->getProperty('foreignKeys'); - $property->setAccessible(true); - $foreignKeys = $property->getValue($model); - - if (is_array($foreignKeys) && ! empty($foreignKeys)) { - $firstFK = $foreignKeys[0]; - if (is_string($firstFK) && str_contains($firstFK, '.')) { - $parts = explode('.', $firstFK); + $foreignKeys = $this->getForeignKeys($model); + if (! empty($foreignKeys)) { + $firstFK = $foreignKeys[0]; - return end($parts); - } - - return $firstFK; - } - } - } catch (\Exception $e) { - // Fallback + return $this->extractColumnFromQualified($firstFK); } // Default: assume intermediate table has a foreign key named {parent_table}_id - return $parent->getTable().'_id'; + return \Illuminate\Support\Str::singular($parent->getTable()).'_id'; } /** * Get the local key for joining from the parent to the intermediate table. * - * @param Relation $model + * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep $model */ protected function getHasManyDeepIntermediateLocalKey($model): string { @@ -582,4 +527,73 @@ protected function performJoin($table, $foreign, $other, $type = 'left'): void $this->getBaseQueryBuilder()->join($table, $foreign, '=', $other, $type); } } + + /** + * Extract the array of foreign keys from a HasManyDeep relationship using reflection. + * + * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep $model + * @return array + */ + private function getForeignKeys($model): array + { + try { + $reflection = new \ReflectionClass($model); + if ($reflection->hasProperty('foreignKeys')) { + $property = $reflection->getProperty('foreignKeys'); + $property->setAccessible(true); + $foreignKeys = $property->getValue($model); + if (is_array($foreignKeys) && ! empty($foreignKeys)) { + return $foreignKeys; + } + } + } catch (\Exception $e) { + // Reflection failed - fall back to empty array + // This is safe because callers handle empty arrays appropriately + } + + return []; + } + + /** + * Extract the array of through models from a HasManyDeep relationship using reflection. + * + * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep $model + * @return array + */ + private function getThroughModels($model): array + { + try { + $reflection = new \ReflectionClass($model); + if ($reflection->hasProperty('through')) { + $property = $reflection->getProperty('through'); + $property->setAccessible(true); + $through = $property->getValue($model); + if (is_array($through) && ! empty($through)) { + return $through; + } + } + } catch (\Exception $e) { + // Reflection failed - fall back to empty array + // This is safe because callers handle empty arrays appropriately + } + + return []; + } + + /** + * Extract the column name from a qualified column name (e.g., 'table.column' -> 'column'). + * + * @param string $qualified + * @return string + */ + private function extractColumnFromQualified(string $qualified): string + { + if (str_contains($qualified, '.')) { + $parts = explode('.', $qualified); + + return end($parts); + } + + return $qualified; + } } diff --git a/tests/Integration/HasManyDeepRelationTest.php b/tests/Integration/HasManyDeepRelationTest.php index 49e71f87..1e9dab6e 100644 --- a/tests/Integration/HasManyDeepRelationTest.php +++ b/tests/Integration/HasManyDeepRelationTest.php @@ -58,13 +58,16 @@ public function it_can_perform_global_search_on_the_relation() 'search' => ['value' => 'Comment-1'], ]); + // HasManyDeep can return multiple rows per user (one per comment matching the search) + // Each user has 3 posts with 2 comments each. Searching for 'Comment-1' matches + // one comment per post, so we expect at least 20 users × 3 posts = 60 results $response->assertJson([ 'draw' => 0, 'recordsTotal' => 20, - 'recordsFiltered' => 20, ]); - $this->assertCount(20, $response->json()['data']); + $this->assertGreaterThanOrEqual(60, $response->json()['recordsFiltered']); + $this->assertGreaterThanOrEqual(60, count($response->json()['data'])); } #[Test] From a29ee91e8116256ff6b03dfa8321039d04f0f088 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Nov 2025 06:15:33 +0000 Subject: [PATCH 05/20] fix: pint :robot: --- src/EloquentDataTable.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/EloquentDataTable.php b/src/EloquentDataTable.php index 86562393..a02005b9 100644 --- a/src/EloquentDataTable.php +++ b/src/EloquentDataTable.php @@ -532,7 +532,6 @@ protected function performJoin($table, $foreign, $other, $type = 'left'): void * Extract the array of foreign keys from a HasManyDeep relationship using reflection. * * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep $model - * @return array */ private function getForeignKeys($model): array { @@ -558,7 +557,6 @@ private function getForeignKeys($model): array * Extract the array of through models from a HasManyDeep relationship using reflection. * * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep $model - * @return array */ private function getThroughModels($model): array { @@ -582,9 +580,6 @@ private function getThroughModels($model): array /** * Extract the column name from a qualified column name (e.g., 'table.column' -> 'column'). - * - * @param string $qualified - * @return string */ private function extractColumnFromQualified(string $qualified): string { From 38ea9bfd39cec7c90240e6571c76342eaf3a3a80 Mon Sep 17 00:00:00 2001 From: Arjay Angeles Date: Tue, 18 Nov 2025 14:25:25 +0800 Subject: [PATCH 06/20] Fix HasManyDeep global search test expectation - Update test to expect 20 unique users instead of 60 comment rows - Global search on HasManyDeep returns unique parent records when selecting from parent table - This matches the behavior of other has-many relationships in the codebase --- tests/Integration/HasManyDeepRelationTest.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Integration/HasManyDeepRelationTest.php b/tests/Integration/HasManyDeepRelationTest.php index 1e9dab6e..e2856cbf 100644 --- a/tests/Integration/HasManyDeepRelationTest.php +++ b/tests/Integration/HasManyDeepRelationTest.php @@ -58,16 +58,16 @@ public function it_can_perform_global_search_on_the_relation() 'search' => ['value' => 'Comment-1'], ]); - // HasManyDeep can return multiple rows per user (one per comment matching the search) - // Each user has 3 posts with 2 comments each. Searching for 'Comment-1' matches - // one comment per post, so we expect at least 20 users × 3 posts = 60 results + // Global search on HasManyDeep relationship returns unique users that have matching comments + // Since we're selecting users.*, we get one row per user, not one row per matching comment + // All 20 users have comments with 'Comment-1', so we expect 20 results $response->assertJson([ 'draw' => 0, 'recordsTotal' => 20, + 'recordsFiltered' => 20, ]); - $this->assertGreaterThanOrEqual(60, $response->json()['recordsFiltered']); - $this->assertGreaterThanOrEqual(60, count($response->json()['data'])); + $this->assertCount(20, $response->json()['data']); } #[Test] From 14d5ad5ca1bec8b80f28ea6a2f34539ec839aa12 Mon Sep 17 00:00:00 2001 From: Arjay Angeles Date: Fri, 28 Nov 2025 14:19:51 +0800 Subject: [PATCH 07/20] chore: move package to require-dev --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index ff0d322d..ec071851 100644 --- a/composer.json +++ b/composer.json @@ -20,8 +20,7 @@ "illuminate/filesystem": "^12", "illuminate/http": "^12", "illuminate/support": "^12", - "illuminate/view": "^12", - "staudenmeir/eloquent-has-many-deep": "^1.21" + "illuminate/view": "^12" }, "require-dev": { "algolia/algoliasearch-client-php": "^3.4.1", @@ -30,7 +29,8 @@ "laravel/scout": "^10.8.3", "meilisearch/meilisearch-php": "^1.6.1", "orchestra/testbench": "^10", - "rector/rector": "^2.0" + "rector/rector": "^2.0", + "staudenmeir/eloquent-has-many-deep": "^1.21" }, "suggest": { "yajra/laravel-datatables-export": "Plugin for server-side exporting using livewire and queue worker.", From f306f1eba733cb89af17763d06659cc8a2c9d100 Mon Sep 17 00:00:00 2001 From: Arjay Angeles Date: Fri, 28 Nov 2025 14:34:02 +0800 Subject: [PATCH 08/20] refactor: This method has 5 returns, which is more than the 3 allowed. --- src/EloquentDataTable.php | 103 +++++++++++++++++--------------------- 1 file changed, 45 insertions(+), 58 deletions(-) diff --git a/src/EloquentDataTable.php b/src/EloquentDataTable.php index a02005b9..3c21ed9e 100644 --- a/src/EloquentDataTable.php +++ b/src/EloquentDataTable.php @@ -167,7 +167,7 @@ protected function isMorphRelation($relation) */ protected function isHasManyDeep($model): bool { - return class_exists('Staudenmeir\EloquentHasManyDeep\HasManyDeep') + return class_exists(\Staudenmeir\EloquentHasManyDeep\HasManyDeep::class) && $model instanceof \Staudenmeir\EloquentHasManyDeep\HasManyDeep; } @@ -182,10 +182,7 @@ protected function getHasManyDeepForeignKey($model): string // Try to get from relationship definition using reflection $foreignKeys = $this->getForeignKeys($model); if (! empty($foreignKeys)) { - // Get the last foreign key (for the final join) - $lastFK = end($foreignKeys); - - return $this->extractColumnFromQualified($lastFK); + return $this->extractColumnFromQualified(end($foreignKeys)); } // Try to get the foreign key using common HasManyDeep methods @@ -193,22 +190,13 @@ protected function getHasManyDeepForeignKey($model): string return $model->getForeignKeyName(); } - // HasManyDeep may use getQualifiedForeignKeyName() and extract the column - if (method_exists($model, 'getQualifiedForeignKeyName')) { - $qualified = $model->getQualifiedForeignKeyName(); - - return $this->extractColumnFromQualified($qualified); - } - - // Fallback: try to infer from intermediate model + // Fallback: try to infer from intermediate model or use related model's key $intermediateTable = $this->getHasManyDeepIntermediateTable($model, ''); - if ($intermediateTable) { - // Assume the related table has a foreign key named {intermediate_table}_id - return \Illuminate\Support\Str::singular($intermediateTable).'_id'; - } + $fallbackKey = $intermediateTable + ? \Illuminate\Support\Str::singular($intermediateTable).'_id' + : $model->getRelated()->getKeyName(); - // Final fallback: use the related model's key name - return $model->getRelated()->getKeyName(); + return $fallbackKey; } /** @@ -220,24 +208,9 @@ protected function getHasManyDeepForeignKey($model): string protected function getHasManyDeepLocalKey($model): string { // Try to get from relationship definition using reflection - $localKeys = []; - try { - $reflection = new \ReflectionClass($model); - if ($reflection->hasProperty('localKeys')) { - $property = $reflection->getProperty('localKeys'); - $property->setAccessible(true); - $localKeys = $property->getValue($model); - } - } catch (\Exception $e) { - // Reflection failed - proceed to other methods - // This is safe because we have multiple fallback strategies - } - - if (is_array($localKeys) && ! empty($localKeys)) { - // Get the last local key (for the final join) - $lastLK = end($localKeys); - - return $this->extractColumnFromQualified($lastLK); + $localKeys = $this->getLocalKeys($model); + if (! empty($localKeys)) { + return $this->extractColumnFromQualified(end($localKeys)); } // Try to get the local key using common HasManyDeep methods @@ -245,29 +218,18 @@ protected function getHasManyDeepLocalKey($model): string return $model->getLocalKeyName(); } - // HasManyDeep may use getQualifiedLocalKeyName() and extract the column - if (method_exists($model, 'getQualifiedLocalKeyName')) { - $qualified = $model->getQualifiedLocalKeyName(); - - return $this->extractColumnFromQualified($qualified); - } - // Fallback: use the intermediate model's key name, or parent if no intermediate $intermediateTable = $this->getHasManyDeepIntermediateTable($model, ''); - if ($intermediateTable) { - $through = $this->getThroughModels($model); - if (! empty($through)) { - $firstThrough = is_string($through[0]) ? $through[0] : get_class($through[0]); - if (class_exists($firstThrough)) { - $throughModel = app($firstThrough); - - return $throughModel->getKeyName(); - } + $through = $this->getThroughModels($model); + $fallbackKey = $model->getParent()->getKeyName(); + if ($intermediateTable && ! empty($through)) { + $firstThrough = is_string($through[0]) ? $through[0] : $through[0]::class; + if (class_exists($firstThrough)) { + $fallbackKey = app($firstThrough)->getKeyName(); } } - // Final fallback: use the parent model's key name - return $model->getParent()->getKeyName(); + return $fallbackKey; } /** @@ -283,7 +245,7 @@ protected function getHasManyDeepIntermediateTable($model, $lastAlias): ?string $through = $this->getThroughModels($model); if (! empty($through)) { // Get the first intermediate model - $firstThrough = is_string($through[0]) ? $through[0] : get_class($through[0]); + $firstThrough = is_string($through[0]) ? $through[0] : $through[0]::class; if (class_exists($firstThrough)) { $throughModel = app($firstThrough); @@ -545,7 +507,32 @@ private function getForeignKeys($model): array return $foreignKeys; } } - } catch (\Exception $e) { + } catch (\Exception) { + // Reflection failed - fall back to empty array + // This is safe because callers handle empty arrays appropriately + } + + return []; + } + + /** + * Extract the array of local keys from a HasManyDeep relationship using reflection. + * + * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep $model + */ + private function getLocalKeys($model): array + { + try { + $reflection = new \ReflectionClass($model); + if ($reflection->hasProperty('localKeys')) { + $property = $reflection->getProperty('localKeys'); + $property->setAccessible(true); + $localKeys = $property->getValue($model); + if (is_array($localKeys) && ! empty($localKeys)) { + return $localKeys; + } + } + } catch (\Exception) { // Reflection failed - fall back to empty array // This is safe because callers handle empty arrays appropriately } @@ -570,7 +557,7 @@ private function getThroughModels($model): array return $through; } } - } catch (\Exception $e) { + } catch (\Exception) { // Reflection failed - fall back to empty array // This is safe because callers handle empty arrays appropriately } From 98ac062aff6d4134ed852d4191f42c4cac178761 Mon Sep 17 00:00:00 2001 From: Arjay Angeles Date: Fri, 28 Nov 2025 14:43:20 +0800 Subject: [PATCH 09/20] fix: static analysis --- src/EloquentDataTable.php | 23 ++++++++++++----------- src/QueryDataTable.php | 2 +- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/EloquentDataTable.php b/src/EloquentDataTable.php index 3c21ed9e..14a61461 100644 --- a/src/EloquentDataTable.php +++ b/src/EloquentDataTable.php @@ -163,7 +163,7 @@ protected function isMorphRelation($relation) /** * Check if a relation is a HasManyDeep relationship. * - * @param \Illuminate\Database\Eloquent\Relations\Relation $model + * @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model, mixed> $model */ protected function isHasManyDeep($model): bool { @@ -175,7 +175,7 @@ protected function isHasManyDeep($model): bool * Get the foreign key name for a HasManyDeep relationship. * This is the foreign key on the final related table that points to the intermediate table. * - * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep $model + * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model */ protected function getHasManyDeepForeignKey($model): string { @@ -203,7 +203,7 @@ protected function getHasManyDeepForeignKey($model): string * Get the local key name for a HasManyDeep relationship. * This is the local key on the intermediate table (or parent if no intermediate). * - * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep $model + * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model */ protected function getHasManyDeepLocalKey($model): string { @@ -223,7 +223,7 @@ protected function getHasManyDeepLocalKey($model): string $through = $this->getThroughModels($model); $fallbackKey = $model->getParent()->getKeyName(); if ($intermediateTable && ! empty($through)) { - $firstThrough = is_string($through[0]) ? $through[0] : $through[0]::class; + $firstThrough = is_string($through[0]) ? $through[0] : get_class($through[0]); if (class_exists($firstThrough)) { $fallbackKey = app($firstThrough)->getKeyName(); } @@ -235,7 +235,7 @@ protected function getHasManyDeepLocalKey($model): string /** * Get the intermediate table name for a HasManyDeep relationship. * - * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep $model + * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model * @param string $lastAlias */ protected function getHasManyDeepIntermediateTable($model, $lastAlias): ?string @@ -245,7 +245,7 @@ protected function getHasManyDeepIntermediateTable($model, $lastAlias): ?string $through = $this->getThroughModels($model); if (! empty($through)) { // Get the first intermediate model - $firstThrough = is_string($through[0]) ? $through[0] : $through[0]::class; + $firstThrough = is_string($through[0]) ? $through[0] : get_class($through[0]); if (class_exists($firstThrough)) { $throughModel = app($firstThrough); @@ -259,7 +259,7 @@ protected function getHasManyDeepIntermediateTable($model, $lastAlias): ?string /** * Get the foreign key for joining to the intermediate table. * - * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep $model + * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model */ protected function getHasManyDeepIntermediateForeignKey($model): string { @@ -282,7 +282,7 @@ protected function getHasManyDeepIntermediateForeignKey($model): string /** * Get the local key for joining from the parent to the intermediate table. * - * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep $model + * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model */ protected function getHasManyDeepIntermediateLocalKey($model): string { @@ -402,6 +402,7 @@ protected function joinEagerLoadedColumn($relation, $relationColumn) case $this->isHasManyDeep($model): // HasManyDeep relationships can traverse multiple intermediate models // We need to join through all intermediate models to reach the final related table + /** @var \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model */ $related = $model->getRelated(); // Get the qualified parent key to determine the first intermediate model @@ -493,7 +494,7 @@ protected function performJoin($table, $foreign, $other, $type = 'left'): void /** * Extract the array of foreign keys from a HasManyDeep relationship using reflection. * - * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep $model + * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model */ private function getForeignKeys($model): array { @@ -518,7 +519,7 @@ private function getForeignKeys($model): array /** * Extract the array of local keys from a HasManyDeep relationship using reflection. * - * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep $model + * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model */ private function getLocalKeys($model): array { @@ -543,7 +544,7 @@ private function getLocalKeys($model): array /** * Extract the array of through models from a HasManyDeep relationship using reflection. * - * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep $model + * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model */ private function getThroughModels($model): array { diff --git a/src/QueryDataTable.php b/src/QueryDataTable.php index 01cd5b71..80843703 100644 --- a/src/QueryDataTable.php +++ b/src/QueryDataTable.php @@ -382,7 +382,7 @@ public function columnControlSearch(): void if ($type === 'date') { try { // column control replaces / with - on date value - if ($mask && str_contains($mask, '/')) { + if ($mask && str_contains((string) $mask, '/')) { $value = str_replace('-', '/', $value); } From 5659c8493d67c6fcb5e3d60fbb50c6a7a30126fe Mon Sep 17 00:00:00 2001 From: Arjay Angeles Date: Fri, 28 Nov 2025 14:46:25 +0800 Subject: [PATCH 10/20] refactor: replace hardcoded route with constant in HasManyDeepRelationTest --- tests/Integration/HasManyDeepRelationTest.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/Integration/HasManyDeepRelationTest.php b/tests/Integration/HasManyDeepRelationTest.php index e2856cbf..af44fb98 100644 --- a/tests/Integration/HasManyDeepRelationTest.php +++ b/tests/Integration/HasManyDeepRelationTest.php @@ -12,10 +12,12 @@ class HasManyDeepRelationTest extends TestCase { use DatabaseTransactions; + private const ROUTE_HAS_MANY_DEEP = '/relations/hasManyDeep'; + #[Test] public function it_returns_all_records_with_the_relation_when_called_without_parameters() { - $response = $this->call('GET', '/relations/hasManyDeep'); + $response = $this->call('GET', self::ROUTE_HAS_MANY_DEEP); $response->assertJson([ 'draw' => 0, 'recordsTotal' => 20, @@ -73,7 +75,7 @@ public function it_can_perform_global_search_on_the_relation() #[Test] public function it_can_order_by_has_many_deep_relation_column() { - $response = $this->call('GET', '/relations/hasManyDeep', [ + $response = $this->call('GET', self::ROUTE_HAS_MANY_DEEP, [ 'columns' => [ ['data' => 'comments.content', 'name' => 'comments.content', 'searchable' => true, 'orderable' => true], ['data' => 'name', 'name' => 'name', 'searchable' => true, 'orderable' => true], @@ -105,14 +107,14 @@ protected function getJsonResponse(array $params = []) ], ]; - return $this->call('GET', '/relations/hasManyDeep', array_merge($data, $params)); + return $this->call('GET', self::ROUTE_HAS_MANY_DEEP, array_merge($data, $params)); } protected function setUp(): void { parent::setUp(); - $this->app['router']->get('/relations/hasManyDeep', fn (DataTables $datatables) => $datatables->eloquent(User::with('comments')->select('users.*'))->toJson()); + $this->app['router']->get(self::ROUTE_HAS_MANY_DEEP, fn (DataTables $datatables) => $datatables->eloquent(User::with('comments')->select('users.*'))->toJson()); $this->app['router']->get('/relations/hasManyDeepSearchRelation', fn (DataTables $datatables) => $datatables->eloquent(User::with('comments'))->toJson()); } From ae9565f107b80d6e84aff0d0716bb299c651ba3d Mon Sep 17 00:00:00 2001 From: Arjay Angeles Date: Fri, 28 Nov 2025 14:47:29 +0800 Subject: [PATCH 11/20] refactor: simplify getHasManyDeepIntermediateTable method by removing unused parameter --- src/EloquentDataTable.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/EloquentDataTable.php b/src/EloquentDataTable.php index 14a61461..fc068bc2 100644 --- a/src/EloquentDataTable.php +++ b/src/EloquentDataTable.php @@ -191,7 +191,7 @@ protected function getHasManyDeepForeignKey($model): string } // Fallback: try to infer from intermediate model or use related model's key - $intermediateTable = $this->getHasManyDeepIntermediateTable($model, ''); + $intermediateTable = $this->getHasManyDeepIntermediateTable($model); $fallbackKey = $intermediateTable ? \Illuminate\Support\Str::singular($intermediateTable).'_id' : $model->getRelated()->getKeyName(); @@ -219,7 +219,7 @@ protected function getHasManyDeepLocalKey($model): string } // Fallback: use the intermediate model's key name, or parent if no intermediate - $intermediateTable = $this->getHasManyDeepIntermediateTable($model, ''); + $intermediateTable = $this->getHasManyDeepIntermediateTable($model); $through = $this->getThroughModels($model); $fallbackKey = $model->getParent()->getKeyName(); if ($intermediateTable && ! empty($through)) { @@ -236,9 +236,8 @@ protected function getHasManyDeepLocalKey($model): string * Get the intermediate table name for a HasManyDeep relationship. * * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model - * @param string $lastAlias */ - protected function getHasManyDeepIntermediateTable($model, $lastAlias): ?string + protected function getHasManyDeepIntermediateTable($model): ?string { // Try to get intermediate models from the relationship // HasManyDeep stores intermediate models in a protected property @@ -412,7 +411,7 @@ protected function joinEagerLoadedColumn($relation, $relationColumn) // For HasManyDeep, we need to join through intermediate models // The relationship query already knows the structure, so we'll use it // First, join to the first intermediate model (if not already joined) - $intermediateTable = $this->getHasManyDeepIntermediateTable($model, $lastAlias); + $intermediateTable = $this->getHasManyDeepIntermediateTable($model); if ($intermediateTable && $intermediateTable !== $lastAlias) { // Join to intermediate table first From 9629edf5386cb7778fa8269e67df913b4ed880a5 Mon Sep 17 00:00:00 2001 From: Arjay Angeles Date: Fri, 28 Nov 2025 14:48:19 +0800 Subject: [PATCH 12/20] refactor: streamline fallback key retrieval in EloquentDataTable methods --- src/EloquentDataTable.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/EloquentDataTable.php b/src/EloquentDataTable.php index fc068bc2..1e6d342f 100644 --- a/src/EloquentDataTable.php +++ b/src/EloquentDataTable.php @@ -192,11 +192,10 @@ protected function getHasManyDeepForeignKey($model): string // Fallback: try to infer from intermediate model or use related model's key $intermediateTable = $this->getHasManyDeepIntermediateTable($model); - $fallbackKey = $intermediateTable + + return $intermediateTable ? \Illuminate\Support\Str::singular($intermediateTable).'_id' : $model->getRelated()->getKeyName(); - - return $fallbackKey; } /** @@ -221,15 +220,14 @@ protected function getHasManyDeepLocalKey($model): string // Fallback: use the intermediate model's key name, or parent if no intermediate $intermediateTable = $this->getHasManyDeepIntermediateTable($model); $through = $this->getThroughModels($model); - $fallbackKey = $model->getParent()->getKeyName(); if ($intermediateTable && ! empty($through)) { $firstThrough = is_string($through[0]) ? $through[0] : get_class($through[0]); if (class_exists($firstThrough)) { - $fallbackKey = app($firstThrough)->getKeyName(); + return app($firstThrough)->getKeyName(); } } - return $fallbackKey; + return $model->getParent()->getKeyName(); } /** From f89f2760c38e81c8d6ba30960b9d87801eaefe81 Mon Sep 17 00:00:00 2001 From: Arjay Angeles Date: Fri, 28 Nov 2025 14:51:27 +0800 Subject: [PATCH 13/20] refactor: remove redundant parent key retrieval in EloquentDataTable --- src/EloquentDataTable.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/EloquentDataTable.php b/src/EloquentDataTable.php index 1e6d342f..c22c8185 100644 --- a/src/EloquentDataTable.php +++ b/src/EloquentDataTable.php @@ -402,10 +402,6 @@ protected function joinEagerLoadedColumn($relation, $relationColumn) /** @var \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model */ $related = $model->getRelated(); - // Get the qualified parent key to determine the first intermediate model - $qualifiedParentKey = $model->getQualifiedParentKeyName(); - $parentTable = explode('.', $qualifiedParentKey)[0]; - // For HasManyDeep, we need to join through intermediate models // The relationship query already knows the structure, so we'll use it // First, join to the first intermediate model (if not already joined) From 9fa0293b74eb42e2cfd5dfdf470c281cbfd81ff6 Mon Sep 17 00:00:00 2001 From: Arjay Angeles Date: Fri, 28 Nov 2025 14:53:18 +0800 Subject: [PATCH 14/20] refactor: add comments for safe access to protected properties in EloquentDataTable --- src/EloquentDataTable.php | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/EloquentDataTable.php b/src/EloquentDataTable.php index c22c8185..e7ba1c0b 100644 --- a/src/EloquentDataTable.php +++ b/src/EloquentDataTable.php @@ -495,8 +495,10 @@ private function getForeignKeys($model): array $reflection = new \ReflectionClass($model); if ($reflection->hasProperty('foreignKeys')) { $property = $reflection->getProperty('foreignKeys'); - $property->setAccessible(true); - $foreignKeys = $property->getValue($model); + // Safe: Accessing protected property from third-party package (staudenmeir/eloquent-has-many-deep) + // The property exists and is part of the package's internal API + $property->setAccessible(true); // NOSONAR + $foreignKeys = $property->getValue($model); // NOSONAR if (is_array($foreignKeys) && ! empty($foreignKeys)) { return $foreignKeys; } @@ -520,8 +522,10 @@ private function getLocalKeys($model): array $reflection = new \ReflectionClass($model); if ($reflection->hasProperty('localKeys')) { $property = $reflection->getProperty('localKeys'); - $property->setAccessible(true); - $localKeys = $property->getValue($model); + // Safe: Accessing protected property from third-party package (staudenmeir/eloquent-has-many-deep) + // The property exists and is part of the package's internal API + $property->setAccessible(true); // NOSONAR + $localKeys = $property->getValue($model); // NOSONAR if (is_array($localKeys) && ! empty($localKeys)) { return $localKeys; } @@ -545,8 +549,10 @@ private function getThroughModels($model): array $reflection = new \ReflectionClass($model); if ($reflection->hasProperty('through')) { $property = $reflection->getProperty('through'); - $property->setAccessible(true); - $through = $property->getValue($model); + // Safe: Accessing protected property from third-party package (staudenmeir/eloquent-has-many-deep) + // The property exists and is part of the package's internal API + $property->setAccessible(true); // NOSONAR + $through = $property->getValue($model); // NOSONAR if (is_array($through) && ! empty($through)) { return $through; } From a7498ef6bc78000f203da6ea08fc58639f260198 Mon Sep 17 00:00:00 2001 From: Arjay Angeles Date: Fri, 28 Nov 2025 15:02:31 +0800 Subject: [PATCH 15/20] refactor: enhance key retrieval logic for array manipulations across multiple classes --- src/CollectionDataTable.php | 3 ++- src/DataTableAbstract.php | 5 ++--- src/DataTables.php | 28 ++++++++++++++++------------ src/Processors/DataProcessor.php | 3 ++- src/Utilities/Helper.php | 14 +++++++------- 5 files changed, 29 insertions(+), 24 deletions(-) diff --git a/src/CollectionDataTable.php b/src/CollectionDataTable.php index 36ca5184..c94bdb5f 100644 --- a/src/CollectionDataTable.php +++ b/src/CollectionDataTable.php @@ -191,7 +191,8 @@ private function revertIndexColumn($mDataSupport): void $start = $this->request->start(); $this->collection->transform(function ($data) use ($index, &$start) { - $data[$index] = ++$start; + $indexKey = is_string($index) || is_int($index) ? $index : 0; + $data[$indexKey] = ++$start; return $data; }); diff --git a/src/DataTableAbstract.php b/src/DataTableAbstract.php index 8d0d0c73..acbbef2e 100644 --- a/src/DataTableAbstract.php +++ b/src/DataTableAbstract.php @@ -433,9 +433,8 @@ public function with(mixed $key, mixed $value = ''): static if (is_array($key)) { $this->appends = $key; } else { - /** @var int|string $arrayKey */ - $arrayKey = is_int($key) || is_string($key) ? $key : (string) $key; - $this->appends[$arrayKey] = value($value); + $appendsKey = is_string($key) || is_int($key) ? $key : (string) $key; + $this->appends[$appendsKey] = value($value); } return $this; diff --git a/src/DataTables.php b/src/DataTables.php index a7e2339d..7ec8c540 100644 --- a/src/DataTables.php +++ b/src/DataTables.php @@ -4,10 +4,9 @@ use Illuminate\Contracts\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Contracts\Database\Query\Builder as QueryBuilder; -use Illuminate\Support\Facades\Config; use Illuminate\Support\Traits\Macroable; use Yajra\DataTables\Exceptions\Exception; -use Yajra\DataTables\Utilities\Config as DataTablesConfig; +use Yajra\DataTables\Utilities\Config; use Yajra\DataTables\Utilities\Request; class DataTables @@ -43,15 +42,17 @@ public static function of($source) */ public static function make($source) { - $engines = Config::array('datatables.engines', []); - $builders = Config::array('datatables.builders', []); + $engines = (array) config('datatables.engines'); + $builders = (array) config('datatables.builders'); $args = func_get_args(); foreach ($builders as $class => $engine) { - if (is_string($class) && $source instanceof $class) { - /** @var int|string $engineKey */ - $engineKey = is_int($engine) || is_string($engine) ? $engine : (string) $engine; - $callback = [$engines[$engineKey], 'create']; + if (is_string($class) && class_exists($class) && $source instanceof $class) { + $engineClass = is_string($engine) && isset($engines[$engine]) ? $engines[$engine] : null; + if ($engineClass === null) { + continue; + } + $callback = [$engineClass, 'create']; if (is_callable($callback)) { /** @var \Yajra\DataTables\DataTableAbstract $instance */ @@ -90,7 +91,7 @@ public function getRequest(): Request /** * Get config instance. */ - public function getConfig(): DataTablesConfig + public function getConfig(): Config { return app('datatables.config'); } @@ -102,7 +103,8 @@ public function getConfig(): DataTablesConfig */ public function query(QueryBuilder $builder): QueryDataTable { - $dataTable = Config::string('datatables.engines.query'); + /** @var string $dataTable */ + $dataTable = config('datatables.engines.query'); $this->validateDataTable($dataTable, QueryDataTable::class); @@ -116,7 +118,8 @@ public function query(QueryBuilder $builder): QueryDataTable */ public function eloquent(EloquentBuilder $builder): EloquentDataTable { - $dataTable = Config::string('datatables.engines.eloquent'); + /** @var string $dataTable */ + $dataTable = config('datatables.engines.eloquent'); $this->validateDataTable($dataTable, EloquentDataTable::class); @@ -132,7 +135,8 @@ public function eloquent(EloquentBuilder $builder): EloquentDataTable */ public function collection($collection): CollectionDataTable { - $dataTable = Config::string('datatables.engines.collection'); + /** @var string $dataTable */ + $dataTable = config('datatables.engines.collection'); $this->validateDataTable($dataTable, CollectionDataTable::class); diff --git a/src/Processors/DataProcessor.php b/src/Processors/DataProcessor.php index f1a42fd9..cbd71822 100644 --- a/src/Processors/DataProcessor.php +++ b/src/Processors/DataProcessor.php @@ -79,7 +79,8 @@ public function process($object = false): array $value = $this->removeExcessColumns($value); if ($this->includeIndex) { - $value[$indexColumn] = ++$this->start; + $indexKey = is_string($indexColumn) ? $indexColumn : 'DT_RowIndex'; + $value[$indexKey] = ++$this->start; } $this->output[] = $object ? $value : $this->flatten($value); diff --git a/src/Utilities/Helper.php b/src/Utilities/Helper.php index 6f389640..24410fc8 100644 --- a/src/Utilities/Helper.php +++ b/src/Utilities/Helper.php @@ -7,7 +7,6 @@ use Illuminate\Contracts\Support\Arrayable; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Blade; -use Illuminate\Support\Facades\Config; use Illuminate\Support\Str; use ReflectionFunction; use ReflectionMethod; @@ -19,11 +18,11 @@ class Helper */ public static function includeInArray(array $item, array $array): array { - /** @var int|string $itemName */ - $itemName = is_int($item['name']) || is_string($item['name']) ? $item['name'] : (string) $item['name']; - + $itemName = isset($item['name']) && is_string($item['name']) ? $item['name'] : ''; + $itemContent = $item['content'] ?? null; + if (self::isItemOrderInvalid($item, $array)) { - return array_merge($array, [$itemName => $item['content']]); + return array_merge($array, [$itemName => $itemContent]); } $count = 0; @@ -40,7 +39,7 @@ public static function includeInArray(array $item, array $array): array $count++; } - return array_merge($first, [$itemName => $item['content']], $last); + return array_merge($first, [$itemName => $itemContent], $last); } /** @@ -358,7 +357,8 @@ public static function isJavascript(string|array|object|null $value, string $key return false; } - $callbacks = Config::array('datatables.callback', ['$', '$.', 'function']); + /** @var array $callbacks */ + $callbacks = config('datatables.callback', ['$', '$.', 'function']); if (Str::startsWith($key, 'language.')) { return false; From fb3ee05761ee65887b909623690eda60c610ba4e Mon Sep 17 00:00:00 2001 From: yajra <2687997+yajra@users.noreply.github.com> Date: Fri, 28 Nov 2025 07:03:07 +0000 Subject: [PATCH 16/20] fix: pint :robot: --- src/Utilities/Helper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Utilities/Helper.php b/src/Utilities/Helper.php index 24410fc8..06fbf552 100644 --- a/src/Utilities/Helper.php +++ b/src/Utilities/Helper.php @@ -20,7 +20,7 @@ public static function includeInArray(array $item, array $array): array { $itemName = isset($item['name']) && is_string($item['name']) ? $item['name'] : ''; $itemContent = $item['content'] ?? null; - + if (self::isItemOrderInvalid($item, $array)) { return array_merge($array, [$itemName => $itemContent]); } From f2725cb31d5dbc82db60e89230194c31b10d5894 Mon Sep 17 00:00:00 2001 From: Arjay Angeles Date: Fri, 28 Nov 2025 15:12:52 +0800 Subject: [PATCH 17/20] refactor: improve DataTable instance creation logic by introducing helper methods for builders and engines --- src/DataTables.php | 143 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 119 insertions(+), 24 deletions(-) diff --git a/src/DataTables.php b/src/DataTables.php index 7ec8c540..7fcd8519 100644 --- a/src/DataTables.php +++ b/src/DataTables.php @@ -42,42 +42,137 @@ public static function of($source) */ public static function make($source) { + $args = func_get_args(); $engines = (array) config('datatables.engines'); $builders = (array) config('datatables.builders'); - $args = func_get_args(); + $instance = self::tryCreateFromBuilders($source, $builders, $engines, $args); + if ($instance !== null) { + return $instance; + } + + $instance = self::tryCreateFromEngines($source, $engines, $args); + if ($instance !== null) { + return $instance; + } + + throw new Exception('No available engine for '.$source::class); + } + + /** + * Try to create a DataTable instance from builders configuration. + * + * @param object $source + * @param array $builders + * @param array $engines + * @param array $args + * @return DataTableAbstract|null + */ + private static function tryCreateFromBuilders($source, array $builders, array $engines, array $args): ?DataTableAbstract + { foreach ($builders as $class => $engine) { - if (is_string($class) && class_exists($class) && $source instanceof $class) { - $engineClass = is_string($engine) && isset($engines[$engine]) ? $engines[$engine] : null; - if ($engineClass === null) { - continue; - } - $callback = [$engineClass, 'create']; - - if (is_callable($callback)) { - /** @var \Yajra\DataTables\DataTableAbstract $instance */ - $instance = call_user_func_array($callback, $args); - - return $instance; - } + if (! self::isValidBuilderClass($source, $class)) { + continue; + } + + $engineClass = self::getEngineClass($engine, $engines); + if ($engineClass === null) { + continue; + } + + $instance = self::createInstance($engineClass, $args); + if ($instance !== null) { + return $instance; } } - foreach ($engines as $engine) { - $canCreate = [$engine, 'canCreate']; - if (is_callable($canCreate) && call_user_func_array($canCreate, $args)) { - $create = [$engine, 'create']; + return null; + } - if (is_callable($create)) { - /** @var \Yajra\DataTables\DataTableAbstract $instance */ - $instance = call_user_func_array($create, $args); + /** + * Try to create a DataTable instance from engines configuration. + * + * @param object $source + * @param array $engines + * @param array $args + * @return DataTableAbstract|null + */ + private static function tryCreateFromEngines($source, array $engines, array $args): ?DataTableAbstract + { + foreach ($engines as $engine) { + if (! self::canCreateInstance($engine, $args)) { + continue; + } - return $instance; - } + $instance = self::createInstance($engine, $args); + if ($instance !== null) { + return $instance; } } - throw new Exception('No available engine for '.$source::class); + return null; + } + + /** + * Check if the source is a valid instance of the builder class. + * + * @param object $source + * @param string $class + * @return bool + */ + private static function isValidBuilderClass($source, $class): bool + { + return is_string($class) && class_exists($class) && $source instanceof $class; + } + + /** + * Get the engine class from the engine name. + * + * @param mixed $engine + * @param array $engines + * @return string|null + */ + private static function getEngineClass($engine, array $engines): ?string + { + if (! is_string($engine) || ! isset($engines[$engine])) { + return null; + } + + return $engines[$engine]; + } + + /** + * Check if an engine can create an instance with the given arguments. + * + * @param string $engine + * @param array $args + * @return bool + */ + private static function canCreateInstance($engine, array $args): bool + { + $canCreate = [$engine, 'canCreate']; + + return is_callable($canCreate) && call_user_func_array($canCreate, $args); + } + + /** + * Create a DataTable instance from the engine class. + * + * @param string $engineClass + * @param array $args + * @return DataTableAbstract|null + */ + private static function createInstance($engineClass, array $args): ?DataTableAbstract + { + $callback = [$engineClass, 'create']; + if (! is_callable($callback)) { + return null; + } + + /** @var \Yajra\DataTables\DataTableAbstract $instance */ + $instance = call_user_func_array($callback, $args); + + return $instance; } /** From 6b2c917767e5fd4d881d83270c02c30611501136 Mon Sep 17 00:00:00 2001 From: yajra <2687997+yajra@users.noreply.github.com> Date: Fri, 28 Nov 2025 07:48:44 +0000 Subject: [PATCH 18/20] fix: pint :robot: --- src/DataTables.php | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/DataTables.php b/src/DataTables.php index 7fcd8519..d38c3521 100644 --- a/src/DataTables.php +++ b/src/DataTables.php @@ -63,10 +63,6 @@ public static function make($source) * Try to create a DataTable instance from builders configuration. * * @param object $source - * @param array $builders - * @param array $engines - * @param array $args - * @return DataTableAbstract|null */ private static function tryCreateFromBuilders($source, array $builders, array $engines, array $args): ?DataTableAbstract { @@ -93,9 +89,6 @@ private static function tryCreateFromBuilders($source, array $builders, array $e * Try to create a DataTable instance from engines configuration. * * @param object $source - * @param array $engines - * @param array $args - * @return DataTableAbstract|null */ private static function tryCreateFromEngines($source, array $engines, array $args): ?DataTableAbstract { @@ -118,7 +111,6 @@ private static function tryCreateFromEngines($source, array $engines, array $arg * * @param object $source * @param string $class - * @return bool */ private static function isValidBuilderClass($source, $class): bool { @@ -129,8 +121,6 @@ private static function isValidBuilderClass($source, $class): bool * Get the engine class from the engine name. * * @param mixed $engine - * @param array $engines - * @return string|null */ private static function getEngineClass($engine, array $engines): ?string { @@ -145,8 +135,6 @@ private static function getEngineClass($engine, array $engines): ?string * Check if an engine can create an instance with the given arguments. * * @param string $engine - * @param array $args - * @return bool */ private static function canCreateInstance($engine, array $args): bool { @@ -159,8 +147,6 @@ private static function canCreateInstance($engine, array $args): bool * Create a DataTable instance from the engine class. * * @param string $engineClass - * @param array $args - * @return DataTableAbstract|null */ private static function createInstance($engineClass, array $args): ?DataTableAbstract { From 558f9b1e51faf162bd44d138bedb94c139ece827 Mon Sep 17 00:00:00 2001 From: Arjay Angeles Date: Fri, 28 Nov 2025 15:51:17 +0800 Subject: [PATCH 19/20] refactor: simplify index handling and improve configuration retrieval across DataTable classes --- src/CollectionDataTable.php | 3 +- src/DataTableAbstract.php | 5 +- src/DataTables.php | 141 ++++++------------------------- src/Processors/DataProcessor.php | 3 +- src/QueryDataTable.php | 2 +- src/Utilities/Helper.php | 12 +-- 6 files changed, 40 insertions(+), 126 deletions(-) diff --git a/src/CollectionDataTable.php b/src/CollectionDataTable.php index c94bdb5f..36ca5184 100644 --- a/src/CollectionDataTable.php +++ b/src/CollectionDataTable.php @@ -191,8 +191,7 @@ private function revertIndexColumn($mDataSupport): void $start = $this->request->start(); $this->collection->transform(function ($data) use ($index, &$start) { - $indexKey = is_string($index) || is_int($index) ? $index : 0; - $data[$indexKey] = ++$start; + $data[$index] = ++$start; return $data; }); diff --git a/src/DataTableAbstract.php b/src/DataTableAbstract.php index acbbef2e..8d0d0c73 100644 --- a/src/DataTableAbstract.php +++ b/src/DataTableAbstract.php @@ -433,8 +433,9 @@ public function with(mixed $key, mixed $value = ''): static if (is_array($key)) { $this->appends = $key; } else { - $appendsKey = is_string($key) || is_int($key) ? $key : (string) $key; - $this->appends[$appendsKey] = value($value); + /** @var int|string $arrayKey */ + $arrayKey = is_int($key) || is_string($key) ? $key : (string) $key; + $this->appends[$arrayKey] = value($value); } return $this; diff --git a/src/DataTables.php b/src/DataTables.php index d38c3521..a7e2339d 100644 --- a/src/DataTables.php +++ b/src/DataTables.php @@ -4,9 +4,10 @@ use Illuminate\Contracts\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Contracts\Database\Query\Builder as QueryBuilder; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Traits\Macroable; use Yajra\DataTables\Exceptions\Exception; -use Yajra\DataTables\Utilities\Config; +use Yajra\DataTables\Utilities\Config as DataTablesConfig; use Yajra\DataTables\Utilities\Request; class DataTables @@ -42,123 +43,40 @@ public static function of($source) */ public static function make($source) { - $args = func_get_args(); - $engines = (array) config('datatables.engines'); - $builders = (array) config('datatables.builders'); - - $instance = self::tryCreateFromBuilders($source, $builders, $engines, $args); - if ($instance !== null) { - return $instance; - } - - $instance = self::tryCreateFromEngines($source, $engines, $args); - if ($instance !== null) { - return $instance; - } - - throw new Exception('No available engine for '.$source::class); - } + $engines = Config::array('datatables.engines', []); + $builders = Config::array('datatables.builders', []); - /** - * Try to create a DataTable instance from builders configuration. - * - * @param object $source - */ - private static function tryCreateFromBuilders($source, array $builders, array $engines, array $args): ?DataTableAbstract - { + $args = func_get_args(); foreach ($builders as $class => $engine) { - if (! self::isValidBuilderClass($source, $class)) { - continue; - } + if (is_string($class) && $source instanceof $class) { + /** @var int|string $engineKey */ + $engineKey = is_int($engine) || is_string($engine) ? $engine : (string) $engine; + $callback = [$engines[$engineKey], 'create']; - $engineClass = self::getEngineClass($engine, $engines); - if ($engineClass === null) { - continue; - } + if (is_callable($callback)) { + /** @var \Yajra\DataTables\DataTableAbstract $instance */ + $instance = call_user_func_array($callback, $args); - $instance = self::createInstance($engineClass, $args); - if ($instance !== null) { - return $instance; + return $instance; + } } } - return null; - } - - /** - * Try to create a DataTable instance from engines configuration. - * - * @param object $source - */ - private static function tryCreateFromEngines($source, array $engines, array $args): ?DataTableAbstract - { foreach ($engines as $engine) { - if (! self::canCreateInstance($engine, $args)) { - continue; - } + $canCreate = [$engine, 'canCreate']; + if (is_callable($canCreate) && call_user_func_array($canCreate, $args)) { + $create = [$engine, 'create']; - $instance = self::createInstance($engine, $args); - if ($instance !== null) { - return $instance; - } - } + if (is_callable($create)) { + /** @var \Yajra\DataTables\DataTableAbstract $instance */ + $instance = call_user_func_array($create, $args); - return null; - } - - /** - * Check if the source is a valid instance of the builder class. - * - * @param object $source - * @param string $class - */ - private static function isValidBuilderClass($source, $class): bool - { - return is_string($class) && class_exists($class) && $source instanceof $class; - } - - /** - * Get the engine class from the engine name. - * - * @param mixed $engine - */ - private static function getEngineClass($engine, array $engines): ?string - { - if (! is_string($engine) || ! isset($engines[$engine])) { - return null; - } - - return $engines[$engine]; - } - - /** - * Check if an engine can create an instance with the given arguments. - * - * @param string $engine - */ - private static function canCreateInstance($engine, array $args): bool - { - $canCreate = [$engine, 'canCreate']; - - return is_callable($canCreate) && call_user_func_array($canCreate, $args); - } - - /** - * Create a DataTable instance from the engine class. - * - * @param string $engineClass - */ - private static function createInstance($engineClass, array $args): ?DataTableAbstract - { - $callback = [$engineClass, 'create']; - if (! is_callable($callback)) { - return null; + return $instance; + } + } } - /** @var \Yajra\DataTables\DataTableAbstract $instance */ - $instance = call_user_func_array($callback, $args); - - return $instance; + throw new Exception('No available engine for '.$source::class); } /** @@ -172,7 +90,7 @@ public function getRequest(): Request /** * Get config instance. */ - public function getConfig(): Config + public function getConfig(): DataTablesConfig { return app('datatables.config'); } @@ -184,8 +102,7 @@ public function getConfig(): Config */ public function query(QueryBuilder $builder): QueryDataTable { - /** @var string $dataTable */ - $dataTable = config('datatables.engines.query'); + $dataTable = Config::string('datatables.engines.query'); $this->validateDataTable($dataTable, QueryDataTable::class); @@ -199,8 +116,7 @@ public function query(QueryBuilder $builder): QueryDataTable */ public function eloquent(EloquentBuilder $builder): EloquentDataTable { - /** @var string $dataTable */ - $dataTable = config('datatables.engines.eloquent'); + $dataTable = Config::string('datatables.engines.eloquent'); $this->validateDataTable($dataTable, EloquentDataTable::class); @@ -216,8 +132,7 @@ public function eloquent(EloquentBuilder $builder): EloquentDataTable */ public function collection($collection): CollectionDataTable { - /** @var string $dataTable */ - $dataTable = config('datatables.engines.collection'); + $dataTable = Config::string('datatables.engines.collection'); $this->validateDataTable($dataTable, CollectionDataTable::class); diff --git a/src/Processors/DataProcessor.php b/src/Processors/DataProcessor.php index cbd71822..f1a42fd9 100644 --- a/src/Processors/DataProcessor.php +++ b/src/Processors/DataProcessor.php @@ -79,8 +79,7 @@ public function process($object = false): array $value = $this->removeExcessColumns($value); if ($this->includeIndex) { - $indexKey = is_string($indexColumn) ? $indexColumn : 'DT_RowIndex'; - $value[$indexKey] = ++$this->start; + $value[$indexColumn] = ++$this->start; } $this->output[] = $object ? $value : $this->flatten($value); diff --git a/src/QueryDataTable.php b/src/QueryDataTable.php index 80843703..01cd5b71 100644 --- a/src/QueryDataTable.php +++ b/src/QueryDataTable.php @@ -382,7 +382,7 @@ public function columnControlSearch(): void if ($type === 'date') { try { // column control replaces / with - on date value - if ($mask && str_contains((string) $mask, '/')) { + if ($mask && str_contains($mask, '/')) { $value = str_replace('-', '/', $value); } diff --git a/src/Utilities/Helper.php b/src/Utilities/Helper.php index 06fbf552..6f389640 100644 --- a/src/Utilities/Helper.php +++ b/src/Utilities/Helper.php @@ -7,6 +7,7 @@ use Illuminate\Contracts\Support\Arrayable; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Str; use ReflectionFunction; use ReflectionMethod; @@ -18,11 +19,11 @@ class Helper */ public static function includeInArray(array $item, array $array): array { - $itemName = isset($item['name']) && is_string($item['name']) ? $item['name'] : ''; - $itemContent = $item['content'] ?? null; + /** @var int|string $itemName */ + $itemName = is_int($item['name']) || is_string($item['name']) ? $item['name'] : (string) $item['name']; if (self::isItemOrderInvalid($item, $array)) { - return array_merge($array, [$itemName => $itemContent]); + return array_merge($array, [$itemName => $item['content']]); } $count = 0; @@ -39,7 +40,7 @@ public static function includeInArray(array $item, array $array): array $count++; } - return array_merge($first, [$itemName => $itemContent], $last); + return array_merge($first, [$itemName => $item['content']], $last); } /** @@ -357,8 +358,7 @@ public static function isJavascript(string|array|object|null $value, string $key return false; } - /** @var array $callbacks */ - $callbacks = config('datatables.callback', ['$', '$.', 'function']); + $callbacks = Config::array('datatables.callback', ['$', '$.', 'function']); if (Str::startsWith($key, 'language.')) { return false; From cefb626cd56277374fc09a105ffd03b08da32d3c Mon Sep 17 00:00:00 2001 From: Arjay Angeles Date: Fri, 28 Nov 2025 15:57:57 +0800 Subject: [PATCH 20/20] feat: add HasManyDeepSupport trait to enhance EloquentDataTable with HasManyDeep relationship handling --- src/Concerns/HasManyDeepSupport.php | 235 ++++++++++++++++++++++++++++ src/EloquentDataTable.php | 225 +------------------------- 2 files changed, 238 insertions(+), 222 deletions(-) create mode 100644 src/Concerns/HasManyDeepSupport.php diff --git a/src/Concerns/HasManyDeepSupport.php b/src/Concerns/HasManyDeepSupport.php new file mode 100644 index 00000000..7593daa9 --- /dev/null +++ b/src/Concerns/HasManyDeepSupport.php @@ -0,0 +1,235 @@ + $model + */ + protected function isHasManyDeep($model): bool + { + return class_exists(\Staudenmeir\EloquentHasManyDeep\HasManyDeep::class) + && $model instanceof \Staudenmeir\EloquentHasManyDeep\HasManyDeep; + } + + /** + * Get the foreign key name for a HasManyDeep relationship. + * This is the foreign key on the final related table that points to the intermediate table. + * + * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model + */ + protected function getHasManyDeepForeignKey($model): string + { + // Try to get from relationship definition using reflection + $foreignKeys = $this->getForeignKeys($model); + if (! empty($foreignKeys)) { + return $this->extractColumnFromQualified(end($foreignKeys)); + } + + // Try to get the foreign key using common HasManyDeep methods + if (method_exists($model, 'getForeignKeyName')) { + return $model->getForeignKeyName(); + } + + // Fallback: try to infer from intermediate model or use related model's key + $intermediateTable = $this->getHasManyDeepIntermediateTable($model); + + return $intermediateTable + ? \Illuminate\Support\Str::singular($intermediateTable).'_id' + : $model->getRelated()->getKeyName(); + } + + /** + * Get the local key name for a HasManyDeep relationship. + * This is the local key on the intermediate table (or parent if no intermediate). + * + * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model + */ + protected function getHasManyDeepLocalKey($model): string + { + // Try to get from relationship definition using reflection + $localKeys = $this->getLocalKeys($model); + if (! empty($localKeys)) { + return $this->extractColumnFromQualified(end($localKeys)); + } + + // Try to get the local key using common HasManyDeep methods + if (method_exists($model, 'getLocalKeyName')) { + return $model->getLocalKeyName(); + } + + // Fallback: use the intermediate model's key name, or parent if no intermediate + $intermediateTable = $this->getHasManyDeepIntermediateTable($model); + $through = $this->getThroughModels($model); + $fallbackKey = $model->getParent()->getKeyName(); + if ($intermediateTable && ! empty($through)) { + $firstThrough = is_string($through[0]) ? $through[0] : get_class($through[0]); + if (class_exists($firstThrough)) { + $fallbackKey = app($firstThrough)->getKeyName(); + } + } + + return $fallbackKey; + } + + /** + * Get the intermediate table name for a HasManyDeep relationship. + * + * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model + */ + protected function getHasManyDeepIntermediateTable($model): ?string + { + // Try to get intermediate models from the relationship + // HasManyDeep stores intermediate models in a protected property + $through = $this->getThroughModels($model); + if (! empty($through)) { + // Get the first intermediate model + $firstThrough = is_string($through[0]) ? $through[0] : get_class($through[0]); + if (class_exists($firstThrough)) { + $throughModel = app($firstThrough); + + return $throughModel->getTable(); + } + } + + return null; + } + + /** + * Get the foreign key for joining to the intermediate table. + * + * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model + */ + protected function getHasManyDeepIntermediateForeignKey($model): string + { + // The foreign key on the intermediate table that points to the parent + // For User -> Posts -> Comments, this would be posts.user_id + $parent = $model->getParent(); + + // Try to get from relationship definition + $foreignKeys = $this->getForeignKeys($model); + if (! empty($foreignKeys)) { + $firstFK = $foreignKeys[0]; + + return $this->extractColumnFromQualified($firstFK); + } + + // Default: assume intermediate table has a foreign key named {parent_table}_id + return \Illuminate\Support\Str::singular($parent->getTable()).'_id'; + } + + /** + * Get the local key for joining from the parent to the intermediate table. + * + * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model + */ + protected function getHasManyDeepIntermediateLocalKey($model): string + { + // The local key on the parent table + return $model->getParent()->getKeyName(); + } + + /** + * Extract the array of foreign keys from a HasManyDeep relationship using reflection. + * + * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model + */ + private function getForeignKeys($model): array + { + try { + $reflection = new \ReflectionClass($model); + if ($reflection->hasProperty('foreignKeys')) { + $property = $reflection->getProperty('foreignKeys'); + // Safe: Accessing protected property from third-party package (staudenmeir/eloquent-has-many-deep) + // The property exists and is part of the package's internal API + $property->setAccessible(true); // NOSONAR + $foreignKeys = $property->getValue($model); // NOSONAR + if (is_array($foreignKeys) && ! empty($foreignKeys)) { + return $foreignKeys; + } + } + } catch (\Exception) { + // Reflection failed - fall back to empty array + // This is safe because callers handle empty arrays appropriately + } + + return []; + } + + /** + * Extract the array of local keys from a HasManyDeep relationship using reflection. + * + * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model + */ + private function getLocalKeys($model): array + { + try { + $reflection = new \ReflectionClass($model); + if ($reflection->hasProperty('localKeys')) { + $property = $reflection->getProperty('localKeys'); + // Safe: Accessing protected property from third-party package (staudenmeir/eloquent-has-many-deep) + // The property exists and is part of the package's internal API + $property->setAccessible(true); // NOSONAR + $localKeys = $property->getValue($model); // NOSONAR + if (is_array($localKeys) && ! empty($localKeys)) { + return $localKeys; + } + } + } catch (\Exception) { + // Reflection failed - fall back to empty array + // This is safe because callers handle empty arrays appropriately + } + + return []; + } + + /** + * Extract the array of through models from a HasManyDeep relationship using reflection. + * + * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model + */ + private function getThroughModels($model): array + { + try { + $reflection = new \ReflectionClass($model); + if ($reflection->hasProperty('through')) { + $property = $reflection->getProperty('through'); + // Safe: Accessing protected property from third-party package (staudenmeir/eloquent-has-many-deep) + // The property exists and is part of the package's internal API + $property->setAccessible(true); // NOSONAR + $through = $property->getValue($model); // NOSONAR + if (is_array($through) && ! empty($through)) { + return $through; + } + } + } catch (\Exception) { + // Reflection failed - fall back to empty array + // This is safe because callers handle empty arrays appropriately + } + + return []; + } + + /** + * Extract the column name from a qualified column name (e.g., 'table.column' -> 'column'). + */ + private function extractColumnFromQualified(string $qualified): string + { + if (str_contains($qualified, '.')) { + $parts = explode('.', $qualified); + + return end($parts); + } + + return $qualified; + } +} diff --git a/src/EloquentDataTable.php b/src/EloquentDataTable.php index e7ba1c0b..c259ae2b 100644 --- a/src/EloquentDataTable.php +++ b/src/EloquentDataTable.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\HasOneThrough; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\Relation; +use Yajra\DataTables\Concerns\HasManyDeepSupport; use Yajra\DataTables\Exceptions\Exception; /** @@ -17,6 +18,8 @@ */ class EloquentDataTable extends QueryDataTable { + use HasManyDeepSupport; + /** * Flag to enable the generation of unique table aliases on eagerly loaded join columns. * You may want to enable it if you encounter a "Not unique table/alias" error when performing a search or applying ordering. @@ -160,133 +163,6 @@ protected function isMorphRelation($relation) return $isMorph; } - /** - * Check if a relation is a HasManyDeep relationship. - * - * @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model, mixed> $model - */ - protected function isHasManyDeep($model): bool - { - return class_exists(\Staudenmeir\EloquentHasManyDeep\HasManyDeep::class) - && $model instanceof \Staudenmeir\EloquentHasManyDeep\HasManyDeep; - } - - /** - * Get the foreign key name for a HasManyDeep relationship. - * This is the foreign key on the final related table that points to the intermediate table. - * - * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model - */ - protected function getHasManyDeepForeignKey($model): string - { - // Try to get from relationship definition using reflection - $foreignKeys = $this->getForeignKeys($model); - if (! empty($foreignKeys)) { - return $this->extractColumnFromQualified(end($foreignKeys)); - } - - // Try to get the foreign key using common HasManyDeep methods - if (method_exists($model, 'getForeignKeyName')) { - return $model->getForeignKeyName(); - } - - // Fallback: try to infer from intermediate model or use related model's key - $intermediateTable = $this->getHasManyDeepIntermediateTable($model); - - return $intermediateTable - ? \Illuminate\Support\Str::singular($intermediateTable).'_id' - : $model->getRelated()->getKeyName(); - } - - /** - * Get the local key name for a HasManyDeep relationship. - * This is the local key on the intermediate table (or parent if no intermediate). - * - * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model - */ - protected function getHasManyDeepLocalKey($model): string - { - // Try to get from relationship definition using reflection - $localKeys = $this->getLocalKeys($model); - if (! empty($localKeys)) { - return $this->extractColumnFromQualified(end($localKeys)); - } - - // Try to get the local key using common HasManyDeep methods - if (method_exists($model, 'getLocalKeyName')) { - return $model->getLocalKeyName(); - } - - // Fallback: use the intermediate model's key name, or parent if no intermediate - $intermediateTable = $this->getHasManyDeepIntermediateTable($model); - $through = $this->getThroughModels($model); - if ($intermediateTable && ! empty($through)) { - $firstThrough = is_string($through[0]) ? $through[0] : get_class($through[0]); - if (class_exists($firstThrough)) { - return app($firstThrough)->getKeyName(); - } - } - - return $model->getParent()->getKeyName(); - } - - /** - * Get the intermediate table name for a HasManyDeep relationship. - * - * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model - */ - protected function getHasManyDeepIntermediateTable($model): ?string - { - // Try to get intermediate models from the relationship - // HasManyDeep stores intermediate models in a protected property - $through = $this->getThroughModels($model); - if (! empty($through)) { - // Get the first intermediate model - $firstThrough = is_string($through[0]) ? $through[0] : get_class($through[0]); - if (class_exists($firstThrough)) { - $throughModel = app($firstThrough); - - return $throughModel->getTable(); - } - } - - return null; - } - - /** - * Get the foreign key for joining to the intermediate table. - * - * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model - */ - protected function getHasManyDeepIntermediateForeignKey($model): string - { - // The foreign key on the intermediate table that points to the parent - // For User -> Posts -> Comments, this would be posts.user_id - $parent = $model->getParent(); - - // Try to get from relationship definition - $foreignKeys = $this->getForeignKeys($model); - if (! empty($foreignKeys)) { - $firstFK = $foreignKeys[0]; - - return $this->extractColumnFromQualified($firstFK); - } - - // Default: assume intermediate table has a foreign key named {parent_table}_id - return \Illuminate\Support\Str::singular($parent->getTable()).'_id'; - } - - /** - * Get the local key for joining from the parent to the intermediate table. - * - * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model - */ - protected function getHasManyDeepIntermediateLocalKey($model): string - { - // The local key on the parent table - return $model->getParent()->getKeyName(); - } - /** * {@inheritDoc} * @@ -483,99 +359,4 @@ protected function performJoin($table, $foreign, $other, $type = 'left'): void $this->getBaseQueryBuilder()->join($table, $foreign, '=', $other, $type); } } - - /** - * Extract the array of foreign keys from a HasManyDeep relationship using reflection. - * - * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model - */ - private function getForeignKeys($model): array - { - try { - $reflection = new \ReflectionClass($model); - if ($reflection->hasProperty('foreignKeys')) { - $property = $reflection->getProperty('foreignKeys'); - // Safe: Accessing protected property from third-party package (staudenmeir/eloquent-has-many-deep) - // The property exists and is part of the package's internal API - $property->setAccessible(true); // NOSONAR - $foreignKeys = $property->getValue($model); // NOSONAR - if (is_array($foreignKeys) && ! empty($foreignKeys)) { - return $foreignKeys; - } - } - } catch (\Exception) { - // Reflection failed - fall back to empty array - // This is safe because callers handle empty arrays appropriately - } - - return []; - } - - /** - * Extract the array of local keys from a HasManyDeep relationship using reflection. - * - * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model - */ - private function getLocalKeys($model): array - { - try { - $reflection = new \ReflectionClass($model); - if ($reflection->hasProperty('localKeys')) { - $property = $reflection->getProperty('localKeys'); - // Safe: Accessing protected property from third-party package (staudenmeir/eloquent-has-many-deep) - // The property exists and is part of the package's internal API - $property->setAccessible(true); // NOSONAR - $localKeys = $property->getValue($model); // NOSONAR - if (is_array($localKeys) && ! empty($localKeys)) { - return $localKeys; - } - } - } catch (\Exception) { - // Reflection failed - fall back to empty array - // This is safe because callers handle empty arrays appropriately - } - - return []; - } - - /** - * Extract the array of through models from a HasManyDeep relationship using reflection. - * - * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model - */ - private function getThroughModels($model): array - { - try { - $reflection = new \ReflectionClass($model); - if ($reflection->hasProperty('through')) { - $property = $reflection->getProperty('through'); - // Safe: Accessing protected property from third-party package (staudenmeir/eloquent-has-many-deep) - // The property exists and is part of the package's internal API - $property->setAccessible(true); // NOSONAR - $through = $property->getValue($model); // NOSONAR - if (is_array($through) && ! empty($through)) { - return $through; - } - } - } catch (\Exception) { - // Reflection failed - fall back to empty array - // This is safe because callers handle empty arrays appropriately - } - - return []; - } - - /** - * Extract the column name from a qualified column name (e.g., 'table.column' -> 'column'). - */ - private function extractColumnFromQualified(string $qualified): string - { - if (str_contains($qualified, '.')) { - $parts = explode('.', $qualified); - - return end($parts); - } - - return $qualified; - } }