Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9f0d145
Add support for HasManyDeep relationships in EloquentDataTable
yajra Nov 18, 2025
56ef619
fix: pint :robot:
yajra Nov 18, 2025
f417853
Initial plan
Copilot Nov 18, 2025
de2cb6d
Address code review feedback for HasManyDeep implementation
Copilot Nov 18, 2025
a29ee91
fix: pint :robot:
Copilot Nov 18, 2025
38ea9bf
Fix HasManyDeep global search test expectation
yajra Nov 18, 2025
14d5ad5
chore: move package to require-dev
yajra Nov 28, 2025
f306f1e
refactor: This method has 5 returns, which is more than the 3 allowed.
yajra Nov 28, 2025
98ac062
fix: static analysis
yajra Nov 28, 2025
5659c84
refactor: replace hardcoded route with constant in HasManyDeepRelatio…
yajra Nov 28, 2025
ae9565f
refactor: simplify getHasManyDeepIntermediateTable method by removing…
yajra Nov 28, 2025
9629edf
refactor: streamline fallback key retrieval in EloquentDataTable methods
yajra Nov 28, 2025
f89f276
refactor: remove redundant parent key retrieval in EloquentDataTable
yajra Nov 28, 2025
9fa0293
refactor: add comments for safe access to protected properties in Elo…
yajra Nov 28, 2025
a7498ef
refactor: enhance key retrieval logic for array manipulations across …
yajra Nov 28, 2025
fb3ee05
fix: pint :robot:
yajra Nov 28, 2025
f2725cb
refactor: improve DataTable instance creation logic by introducing he…
yajra Nov 28, 2025
6b2c917
fix: pint :robot:
yajra Nov 28, 2025
558f9b1
refactor: simplify index handling and improve configuration retrieval…
yajra Nov 28, 2025
cefb626
feat: add HasManyDeepSupport trait to enhance EloquentDataTable with …
yajra Nov 28, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
235 changes: 235 additions & 0 deletions src/Concerns/HasManyDeepSupport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
<?php

namespace Yajra\DataTables\Concerns;

use Illuminate\Database\Eloquent\Relations\Relation;

/**
* Trait to support HasManyDeep relationships in EloquentDataTable.
* This trait encapsulates all HasManyDeep-related methods to keep the main class smaller.
*/
trait HasManyDeepSupport
{
/**
* 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);
$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;
}
}
47 changes: 47 additions & 0 deletions src/EloquentDataTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@
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;

/**
* @property EloquentBuilder $query
*/
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.
Expand Down Expand Up @@ -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.');
}
Expand Down
Loading