Skip to content

Commit be709ca

Browse files
committed
Add support for HasManyDeep relationships in EloquentDataTable
- Updated composer.json to include staudenmeir/eloquent-has-many-deep package. - Implemented methods in EloquentDataTable to handle HasManyDeep relationships, including foreign key and local key retrieval. - Enhanced the User model to utilize HasManyDeep for comments related to posts. - Added comments relationship to Post model. - Updated TestCase to create comments for posts during database seeding.
1 parent 79c601a commit be709ca

File tree

7 files changed

+431
-2
lines changed

7 files changed

+431
-2
lines changed

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
"laravel/scout": "^10.8.3",
3030
"meilisearch/meilisearch-php": "^1.6.1",
3131
"orchestra/testbench": "^10",
32-
"rector/rector": "^2.0"
32+
"rector/rector": "^2.0",
33+
"staudenmeir/eloquent-has-many-deep": "^1.21"
3334
},
3435
"suggest": {
3536
"yajra/laravel-datatables-export": "Plugin for server-side exporting using livewire and queue worker.",

src/EloquentDataTable.php

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,225 @@ protected function isMorphRelation($relation)
160160
return $isMorph;
161161
}
162162

163+
/**
164+
* Check if a relation is a HasManyDeep relationship.
165+
*
166+
* @param Relation $model
167+
* @return bool
168+
*/
169+
protected function isHasManyDeep($model): bool
170+
{
171+
return class_exists('Staudenmeir\EloquentHasManyDeep\HasManyDeep')
172+
&& $model instanceof \Staudenmeir\EloquentHasManyDeep\HasManyDeep;
173+
}
174+
175+
/**
176+
* Get the foreign key name for a HasManyDeep relationship.
177+
* This is the foreign key on the final related table that points to the intermediate table.
178+
*
179+
* @param Relation $model
180+
* @return string
181+
*/
182+
protected function getHasManyDeepForeignKey($model): string
183+
{
184+
// Try to get from relationship definition using reflection
185+
try {
186+
$reflection = new \ReflectionClass($model);
187+
if ($reflection->hasProperty('foreignKeys')) {
188+
$property = $reflection->getProperty('foreignKeys');
189+
$property->setAccessible(true);
190+
$foreignKeys = $property->getValue($model);
191+
192+
if (is_array($foreignKeys) && !empty($foreignKeys)) {
193+
// Get the last foreign key (for the final join)
194+
$lastFK = end($foreignKeys);
195+
if (is_string($lastFK) && str_contains($lastFK, '.')) {
196+
$parts = explode('.', $lastFK);
197+
return end($parts);
198+
}
199+
return $lastFK;
200+
}
201+
}
202+
} catch (\Exception $e) {
203+
// Fallback
204+
}
205+
206+
// Try to get the foreign key using common HasManyDeep methods
207+
if (method_exists($model, 'getForeignKeyName')) {
208+
return $model->getForeignKeyName();
209+
}
210+
211+
// HasManyDeep may use getQualifiedForeignKeyName() and extract the column
212+
if (method_exists($model, 'getQualifiedForeignKeyName')) {
213+
$qualified = $model->getQualifiedForeignKeyName();
214+
$parts = explode('.', $qualified);
215+
return end($parts);
216+
}
217+
218+
// Fallback: try to infer from intermediate model
219+
$intermediateTable = $this->getHasManyDeepIntermediateTable($model, '');
220+
if ($intermediateTable) {
221+
// Assume the related table has a foreign key named {intermediate_table}_id
222+
return $intermediateTable.'_id';
223+
}
224+
225+
// Final fallback: use the related model's key name
226+
return $model->getRelated()->getKeyName();
227+
}
228+
229+
/**
230+
* Get the local key name for a HasManyDeep relationship.
231+
* This is the local key on the intermediate table (or parent if no intermediate).
232+
*
233+
* @param Relation $model
234+
* @return string
235+
*/
236+
protected function getHasManyDeepLocalKey($model): string
237+
{
238+
// Try to get from relationship definition using reflection
239+
try {
240+
$reflection = new \ReflectionClass($model);
241+
if ($reflection->hasProperty('localKeys')) {
242+
$property = $reflection->getProperty('localKeys');
243+
$property->setAccessible(true);
244+
$localKeys = $property->getValue($model);
245+
246+
if (is_array($localKeys) && !empty($localKeys)) {
247+
// Get the last local key (for the final join)
248+
$lastLK = end($localKeys);
249+
if (is_string($lastLK) && str_contains($lastLK, '.')) {
250+
$parts = explode('.', $lastLK);
251+
return end($parts);
252+
}
253+
return $lastLK;
254+
}
255+
}
256+
} catch (\Exception $e) {
257+
// Fallback
258+
}
259+
260+
// Try to get the local key using common HasManyDeep methods
261+
if (method_exists($model, 'getLocalKeyName')) {
262+
return $model->getLocalKeyName();
263+
}
264+
265+
// HasManyDeep may use getQualifiedLocalKeyName() and extract the column
266+
if (method_exists($model, 'getQualifiedLocalKeyName')) {
267+
$qualified = $model->getQualifiedLocalKeyName();
268+
$parts = explode('.', $qualified);
269+
return end($parts);
270+
}
271+
272+
// Fallback: use the intermediate model's key name, or parent if no intermediate
273+
$intermediateTable = $this->getHasManyDeepIntermediateTable($model, '');
274+
if ($intermediateTable) {
275+
try {
276+
$reflection = new \ReflectionClass($model);
277+
if ($reflection->hasProperty('through')) {
278+
$property = $reflection->getProperty('through');
279+
$property->setAccessible(true);
280+
$through = $property->getValue($model);
281+
if (is_array($through) && !empty($through)) {
282+
$firstThrough = is_string($through[0]) ? $through[0] : get_class($through[0]);
283+
if (class_exists($firstThrough)) {
284+
$throughModel = new $firstThrough;
285+
return $throughModel->getKeyName();
286+
}
287+
}
288+
}
289+
} catch (\Exception $e) {
290+
// Fallback
291+
}
292+
}
293+
294+
// Final fallback: use the parent model's key name
295+
return $model->getParent()->getKeyName();
296+
}
297+
298+
/**
299+
* Get the intermediate table name for a HasManyDeep relationship.
300+
*
301+
* @param Relation $model
302+
* @param string $lastAlias
303+
* @return string|null
304+
*/
305+
protected function getHasManyDeepIntermediateTable($model, $lastAlias): ?string
306+
{
307+
// Try to get intermediate models from the relationship
308+
// HasManyDeep stores intermediate models in a protected property
309+
try {
310+
$reflection = new \ReflectionClass($model);
311+
if ($reflection->hasProperty('through')) {
312+
$property = $reflection->getProperty('through');
313+
$property->setAccessible(true);
314+
$through = $property->getValue($model);
315+
316+
if (is_array($through) && !empty($through)) {
317+
// Get the first intermediate model
318+
$firstThrough = is_string($through[0]) ? $through[0] : get_class($through[0]);
319+
if (class_exists($firstThrough)) {
320+
$throughModel = new $firstThrough;
321+
return $throughModel->getTable();
322+
}
323+
}
324+
}
325+
} catch (\Exception $e) {
326+
// Fallback if reflection fails
327+
}
328+
329+
return null;
330+
}
331+
332+
/**
333+
* Get the foreign key for joining to the intermediate table.
334+
*
335+
* @param Relation $model
336+
* @return string
337+
*/
338+
protected function getHasManyDeepIntermediateForeignKey($model): string
339+
{
340+
// The foreign key on the intermediate table that points to the parent
341+
// For User -> Posts -> Comments, this would be posts.user_id
342+
$parent = $model->getParent();
343+
$parentKey = $parent->getKeyName();
344+
345+
// Try to get from relationship definition
346+
try {
347+
$reflection = new \ReflectionClass($model);
348+
if ($reflection->hasProperty('foreignKeys')) {
349+
$property = $reflection->getProperty('foreignKeys');
350+
$property->setAccessible(true);
351+
$foreignKeys = $property->getValue($model);
352+
353+
if (is_array($foreignKeys) && !empty($foreignKeys)) {
354+
$firstFK = $foreignKeys[0];
355+
if (is_string($firstFK) && str_contains($firstFK, '.')) {
356+
$parts = explode('.', $firstFK);
357+
return end($parts);
358+
}
359+
return $firstFK;
360+
}
361+
}
362+
} catch (\Exception $e) {
363+
// Fallback
364+
}
365+
366+
// Default: assume intermediate table has a foreign key named {parent_table}_id
367+
return $parent->getTable().'_id';
368+
}
369+
370+
/**
371+
* Get the local key for joining from the parent to the intermediate table.
372+
*
373+
* @param Relation $model
374+
* @return string
375+
*/
376+
protected function getHasManyDeepIntermediateLocalKey($model): string
377+
{
378+
// The local key on the parent table
379+
return $model->getParent()->getKeyName();
380+
}
381+
163382
/**
164383
* {@inheritDoc}
165384
*
@@ -269,6 +488,53 @@ protected function joinEagerLoadedColumn($relation, $relationColumn)
269488
$other = $tableAlias.'.'.$model->getOwnerKeyName();
270489
break;
271490

491+
case $this->isHasManyDeep($model):
492+
// HasManyDeep relationships can traverse multiple intermediate models
493+
// We need to join through all intermediate models to reach the final related table
494+
$related = $model->getRelated();
495+
496+
// Get the qualified parent key to determine the first intermediate model
497+
$qualifiedParentKey = $model->getQualifiedParentKeyName();
498+
$parentTable = explode('.', $qualifiedParentKey)[0];
499+
500+
// For HasManyDeep, we need to join through intermediate models
501+
// The relationship query already knows the structure, so we'll use it
502+
// First, join to the first intermediate model (if not already joined)
503+
$intermediateTable = $this->getHasManyDeepIntermediateTable($model, $lastAlias);
504+
505+
if ($intermediateTable && $intermediateTable !== $lastAlias) {
506+
// Join to intermediate table first
507+
if ($this->enableEagerJoinAliases) {
508+
$intermediateAlias = $tableAlias.'_intermediate';
509+
$intermediate = $intermediateTable.' as '.$intermediateAlias;
510+
} else {
511+
$intermediateAlias = $intermediateTable;
512+
$intermediate = $intermediateTable;
513+
}
514+
515+
$intermediateFK = $this->getHasManyDeepIntermediateForeignKey($model);
516+
$intermediateLocal = $this->getHasManyDeepIntermediateLocalKey($model);
517+
$this->performJoin($intermediate, $intermediateAlias.'.'.$intermediateFK, ltrim($lastAlias.'.'.$intermediateLocal, '.'));
518+
$lastAlias = $intermediateAlias;
519+
}
520+
521+
// Now join to the final related table
522+
if ($this->enableEagerJoinAliases) {
523+
$table = $related->getTable().' as '.$tableAlias;
524+
} else {
525+
$table = $tableAlias = $related->getTable();
526+
}
527+
528+
// Get the foreign key on the related table (points to intermediate)
529+
$foreignKey = $this->getHasManyDeepForeignKey($model);
530+
$localKey = $this->getHasManyDeepLocalKey($model);
531+
532+
$foreign = $tableAlias.'.'.$foreignKey;
533+
$other = ltrim($lastAlias.'.'.$localKey, '.');
534+
535+
$lastQuery->addSelect($tableAlias.'.'.$relationColumn);
536+
break;
537+
272538
default:
273539
throw new Exception('Relation '.$model::class.' is not yet supported.');
274540
}

0 commit comments

Comments
 (0)