Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
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"
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The package staudenmeir/eloquent-has-many-deep should be added to the require section instead of require-dev since it's used in the production code (EloquentDataTable.php). The require-dev section is for packages only needed during development and testing.

Copilot uses AI. Check for mistakes.
},
"suggest": {
"yajra/laravel-datatables-export": "Plugin for server-side exporting using livewire and queue worker.",
Expand Down
270 changes: 270 additions & 0 deletions src/EloquentDataTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,229 @@ protected function isMorphRelation($relation)
return $isMorph;
}

/**
* Check if a relation is a HasManyDeep relationship.
*
* @param Relation $model
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @param type hint incorrectly states Relation $model but should be more specific like \Staudenmeir\EloquentHasManyDeep\HasManyDeep $model since this method specifically handles HasManyDeep relationships, or use a more generic type if it can handle multiple relation types.

Copilot uses AI. Check for mistakes.
*/
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
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @param type hint incorrectly states Relation $model but should be more specific like \Staudenmeir\EloquentHasManyDeep\HasManyDeep $model to accurately reflect the expected parameter type.

Copilot uses AI. Check for mistakes.
*/
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);
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code block for extracting the column name from a qualified name (lines 193-196) is duplicated in multiple places (also at lines 249-252, 358-361, and similar pattern at lines 214-216, 270-272). Consider extracting this logic into a private helper method like extractColumnFromQualified($qualified) to improve maintainability and reduce duplication.

Copilot uses AI. Check for mistakes.
}

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';
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback logic constructs a foreign key name by concatenating $intermediateTable.'_id', but $intermediateTable could be null (as returned by getHasManyDeepIntermediateTable). This would result in '_id' as the foreign key name, which is likely incorrect. Add a null check before this concatenation.

Copilot uses AI. Check for mistakes.
}

// 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
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @param type hint incorrectly states Relation $model but should be more specific like \Staudenmeir\EloquentHasManyDeep\HasManyDeep $model to accurately reflect the expected parameter type.

Copilot uses AI. Check for mistakes.
*/
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;
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code instantiates a model class without passing any constructor arguments (new $firstThrough). If the model requires constructor arguments, this will fail. Consider using app($firstThrough) to leverage Laravel's container for proper instantiation, or add error handling for this case.

Suggested change
$throughModel = new $firstThrough;
$throughModel = app($firstThrough);

Copilot uses AI. Check for mistakes.

return $throughModel->getKeyName();
}
}
}
} catch (\Exception $e) {
// Fallback
}
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty catch blocks silently swallow all exceptions. Consider logging the exception or adding a comment explaining why it's safe to ignore.

Copilot uses AI. Check for mistakes.
}

// 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
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @param type hint incorrectly states Relation $model but should be more specific like \Staudenmeir\EloquentHasManyDeep\HasManyDeep $model to accurately reflect the expected parameter type.

Copilot uses AI. Check for mistakes.
* @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();
}
}
}
} catch (\Exception $e) {
// Fallback if reflection fails
}

return null;
}

/**
* Get the foreign key for joining to the intermediate table.
*
* @param Relation $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);

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
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @param type hint incorrectly states Relation $model but should be more specific like \Staudenmeir\EloquentHasManyDeep\HasManyDeep $model to accurately reflect the expected parameter type.

Copilot uses AI. Check for mistakes.
*/
protected function getHasManyDeepIntermediateLocalKey($model): string
{
// The local key on the parent table
return $model->getParent()->getKeyName();
}

/**
* {@inheritDoc}
*
Expand Down Expand Up @@ -269,6 +492,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.');
}
Expand Down
Loading