Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
266 changes: 266 additions & 0 deletions src/EloquentDataTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
/**
* @property EloquentBuilder $query
*/
class EloquentDataTable extends QueryDataTable

Check warning on line 18 in src/EloquentDataTable.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Class "EloquentDataTable" has 21 methods, which is greater than 20 authorized. Split it into smaller classes.

See more on https://sonarcloud.io/project/issues?id=yajra_laravel-datatables&issues=AZrJNBlKLKLe6XxID87h&open=AZrJNBlKLKLe6XxID87h&pullRequest=3262
{
/**
* Flag to enable the generation of unique table aliases on eagerly loaded join columns.
Expand Down Expand Up @@ -160,6 +160,133 @@
return $isMorph;
}

/**
* 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

Check warning on line 207 in src/EloquentDataTable.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This method has 4 returns, which is more than the 3 allowed.

See more on https://sonarcloud.io/project/issues?id=yajra_laravel-datatables&issues=AZrJONqJRoKkkBAfz41O&open=AZrJONqJRoKkkBAfz41O&pullRequest=3262
{
// 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);
if ($intermediateTable && ! empty($through)) {
$firstThrough = is_string($through[0]) ? $through[0] : get_class($through[0]);
if (class_exists($firstThrough)) {
return app($firstThrough)->getKeyName();
}
}

return $model->getParent()->getKeyName();
}

/**
* 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();
}

/**
* {@inheritDoc}
*
Expand Down Expand Up @@ -269,6 +396,50 @@
$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 Expand Up @@ -312,4 +483,99 @@
$this->getBaseQueryBuilder()->join($table, $foreign, '=', $other, $type);
}
}

/**
* 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;
}
}
Loading