From be709ca3ed68fcfe97fa8ad392e1c4f8d49ab3f3 Mon Sep 17 00:00:00 2001 From: Arjay Angeles Date: Tue, 18 Nov 2025 13:47:52 +0800 Subject: [PATCH 1/6] 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 b21f1de8e35f9e780f30f3c8167cd7a3b92149af 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 2/6] 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 61367b98668c012e5a5fbad19fa0ecef1369eccf 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 3/6] Initial plan From b6bfe43252a0086945e9bbc51b4cae3d60100681 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 4/6] 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 00fbbda4e62842b5072c58b833ea46306bd02ba1 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 5/6] 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 f03c184d38865c021964ab7365b4db548fb3cf7b Mon Sep 17 00:00:00 2001 From: Arjay Angeles Date: Tue, 18 Nov 2025 14:25:25 +0800 Subject: [PATCH 6/6] 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]