Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -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",
Expand Down
279 changes: 279 additions & 0 deletions src/EloquentDataTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}
*
Expand Down Expand Up @@ -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.');
}
Expand Down Expand Up @@ -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;
}
}
Loading