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/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 a7783d57..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. @@ -269,6 +272,50 @@ 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 + /** @var \Staudenmeir\EloquentHasManyDeep\HasManyDeep<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model> $model */ + $related = $model->getRelated(); + + // 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); + + 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..af44fb98 --- /dev/null +++ b/tests/Integration/HasManyDeepRelationTest.php @@ -0,0 +1,121 @@ +call('GET', self::ROUTE_HAS_MANY_DEEP); + $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', self::ROUTE_HAS_MANY_DEEP, [ + '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', self::ROUTE_HAS_MANY_DEEP, array_merge($data, $params)); + } + + protected function setUp(): void + { + parent::setUp(); + + $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()); + } +} 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([