diff --git a/composer.json b/composer.json index 6e034353..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", diff --git a/src/EloquentDataTable.php b/src/EloquentDataTable.php index a7783d57..a02005b9 100644 --- a/src/EloquentDataTable.php +++ b/src/EloquentDataTable.php @@ -160,6 +160,174 @@ protected function isMorphRelation($relation) return $isMorph; } + /** + * Check if a relation is a HasManyDeep relationship. + * + * @param \Illuminate\Database\Eloquent\Relations\Relation $model + */ + 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 \Staudenmeir\EloquentHasManyDeep\HasManyDeep $model + */ + 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); + } + + // 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(); + + 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 \Illuminate\Support\Str::singular($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 \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); + } + } 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); + } + + // 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(); + + 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(); + } + } + } + + // Final fallback: use the parent model's key name + return $model->getParent()->getKeyName(); + } + + /** + * Get the intermediate table name for a HasManyDeep relationship. + * + * @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 + $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 $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 $model + */ + protected function getHasManyDeepIntermediateLocalKey($model): string + { + // The local key on the parent table + return $model->getParent()->getKeyName(); + } + /** * {@inheritDoc} * @@ -269,6 +437,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.'); } @@ -312,4 +527,68 @@ 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 + */ + 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 + */ + 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'). + */ + 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 new file mode 100644 index 00000000..e2856cbf --- /dev/null +++ b/tests/Integration/HasManyDeepRelationTest.php @@ -0,0 +1,119 @@ +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'], + ]); + + // 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->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..acf12240 --- /dev/null +++ b/tests/Models/Comment.php @@ -0,0 +1,15 @@ +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..9cc3473a 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -83,6 +83,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 +108,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([