Skip to content

Instantly share code, notes, and snippets.

@MatteoOreficeIT
Last active October 14, 2023 08:45
Show Gist options
  • Save MatteoOreficeIT/d3f66e90436dd5b9c90fbe144118e667 to your computer and use it in GitHub Desktop.
Save MatteoOreficeIT/d3f66e90436dd5b9c90fbe144118e667 to your computer and use it in GitHub Desktop.
Laravel Query Join through Relations
<?php
/**
* User: matteo.orefice
* Date: 16/02/2018
* Time: 16:57
*/
namespace MatteoOrefice\Illuminate\Database\Eloquent\Concerns;
use Illuminate\Support\Str;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOneOrMany;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Facades\DB;
/**
* \App\MyModel::query()->select('S.*')->withJoin('relationShip1','S');
* \App\MyModel::query()->select('P.*')->withJoin(['relationShip1','relationShip2'],'P');
* \App\MyModel::query()->select('P.*','S.*')->withJoin(['relationShip1','relationShip2'],['P','S']);
* \App\MyModel::query()->from(\App\MyModel::query()->getModel()->getTable().' as U')->select('U.*','S.*')->withJoin(['relationShip1','relationShip2'],['P','S']);
* \App\MyModel::query()->from(\App\MyModel::query()->getModel()->getTable().' as U')->select('U.*','S.*')->withJoin('relationShip1.relationShip2',['P','S']);
* \App\MyModel::query()->select('P.*','S.*')->withSelect('relationShip1.'relationShip2',['alias'=>'farcolumn']);
* \App\MyModel::query()->select('P.*','S.*')->withSelect('relationShip1.'relationShip2',['alias'=>DB::Expr('farcolumn_expression')]);
* \App\MyModel::query()->select('P.*','S.*')->withSelect('relationShip1.'relationShip2',[DB::Expr('farcolumn_expression')]);
**/
trait JoinRelationShip
{
/**
* @param Builder $builder
* @param $relationSegments
* @param string|string[]|null $rightAlias se non fornito si genera a caso e vendono appesi progressivi
* se fornito come stringa diventa alias di quella piu a dx e le precedenti avranno un suffisso numerico N+1
* se fornito come array, elemento zero viene usato per la relazione piu lontana e cosi via
* @param string $operator
* @return $this
* @throws \Exception
*/
public function scopeWithJoin(Builder $builder, $relationSegments, $rightAlias=null, $decorators=null, $join='join', $operator='=')
{
if(is_string($decorators)) {
if($join === 'join') {
$operator = '=';
} else {
$operator = $join;
}
$join = $decorators;
$decorators = null;
}
else if($decorators) {
$decorators = array_wrap($decorators);
}
// recupera il nome della tabella con eventuale alias dal from oppure dal nome della table corrispondente nel model
$aliasSegments = preg_split('/\s+/i', $previousTableAlias = $builder->getQuery()->from ?: $builder->getModel()->getTable());
// il terzo conterrebbe l'alias
if(is_array($aliasSegments) && isset($aliasSegments[2])) {
$previousTableAlias = $aliasSegments[2];
}
$this->getJoinRelationShipSubQuery($this,$builder,$relationSegments,$previousTableAlias,$rightAlias,false,$decorators,$join,$operator);
return $builder;
}
public function scopeWithSelect(Builder $builder, $relationSegments, $columns, $rightAlias=null, $decorators=null, $join='join', $operator='=')
{
if(is_string($decorators)) {
if($join === 'join') {
$operator = '=';
} else {
$operator = $join;
}
$join = $decorators;
$decorators = null;
}
else if($decorators) {
$decorators = array_wrap($decorators);
}
// recupera il nome della tabella con eventuale alias dal from oppure dal nome della table corrispondente nel model
$aliasSegments = preg_split('/\s+/i', $previousTableAlias = $builder->getQuery()->from ?: $builder->getModel()->getTable());
// il terzo conterrebbe l'alias
if(is_array($aliasSegments) && isset($aliasSegments[2])) {
$previousTableAlias = $aliasSegments[2];
}
$this->getJoinRelationShipSubQuery($this,$subQuery=DB::query(),$relationSegments,$previousTableAlias,$rightAlias,true,$decorators,$join,$operator);
foreach ($columns as $alias => $column) {
$subQuery->addSelect($column);
$builder->selectSub($subQuery , is_string($alias) ? $alias : $this->wrapColumnDefinition($column));
}
return $builder;
}
protected function wrapColumnDefinition($columnDefinition)
{
return urldecode(
$this->getQuery()->getGrammar()->wrap(
preg_replace_callback('/[^a-zA-Z]+/',function($value) {
$value = $value[0];
$out = '';
for ($i = 0; isset($value[$i]); $i++) {
$c = $value[$i];
if (!ctype_alnum($c)) $c = '%' . sprintf('%02X', ord($c));
$out .= $c;
}
return $out;
},$columnDefinition)
)
);
}
/**
* A partire da un model crea join multiple sfruttando le relazioni
*
*
*
* @param Model $model
* @param $relationSegments
* @param null $builder
* @param null $previousTableAlias
* @return null
* @throws \Exception
*/
protected function getJoinRelationShipSubQuery(
Model $model,
$builder,
$relationSegments,
$previousTableAlias=null,
$rightAlias=null,
$sub=false,
$decorators=null,
$join='join',
$operator='=') {
$currentModel = $model;
$relatedModel = null;
$relatedTableAlias = null;
$tableAliases = [];
$relatedTableAndAlias = null;
$relationSegments = array_wrap($relationSegments);
if(count($relationSegments)==1 && Str::contains($relationSegments[0],'.')) {
$relationSegments = preg_split('/\./',$relationSegments[0]);
}
if(($relationIndex=count($relationSegments))==0) {
throw new \Exception('Relation path cannot be empty');
}
/**
* Il prefisso per le tabelle unite in JOIN viene generato a caso se non fornito
*/
$randomPrefix = Str::randomStringAlpha(3);
/**
* Per ogni segmento aggiungo una join
*/
foreach ($relationSegments as $segment) {
if (!method_exists($currentModel,$segment)) {
throw new \BadMethodCallException("Relationship $segment does not exist, cannot join.");
}
$decorator = $this->getDecorator($decorators,$relationIndex);
$relation = $currentModel->$segment();
$relatedModel = $relation->getRelated();
$relatedTableAlias = $this->makeTableAlias($randomPrefix,$rightAlias,$relationIndex);
if(!is_null($relatedTableAlias)) {
$tableAlias = ' AS ' . $relatedTableAlias;
$relatedTableAndAlias = $relatedModel->getTable() . $tableAlias;
}
else {
$relatedTableAndAlias = $relatedTableAlias = $relatedModel->getTable();
}
$tableAliases []= $relatedTableAlias;
/**
* Nelle BelongsTo definiamo :
* - CHILD TABLE(SX) : quella dal lato con cardinalita N
* - FOREIGN KEY : la colonna chiave esterna sulla tabella CHILD ovvero il lato con cardinalita (N)
* - PARENT TABLE(DX) : quella dal lato con cardinalita 1
* - OWNER KEY : la colonna chiave sulla tabella PARENT ovvero il lato con cardinalita (1)
*/
if ($relation instanceof BelongsTo) {
if($sub) {
if(!$previousTableAlias) {
throw new \RuntimeException('$previousTableAlias is required for sub');
}
$sub = false;
$builder
->from($relatedTableAndAlias)
->whereColumn(
$previousTableAlias.'.'. $relation->getForeignKey(),
$operator ,
$relatedTableAlias . '.' . $relation->getOwnerKey()
);
} else {
$builder
->$join(
$relatedTableAndAlias,
function (JoinClause $joinClause) use($decorator,$previousTableAlias,$relation,$operator,$relatedTableAlias) {
$joinClause->on(
$previousTableAlias ? $previousTableAlias.'.'. $relation->getForeignKey() : $relation->getQualifiedForeignKey(),
$operator ,
$relatedTableAlias . '.' . $relation->getOwnerKey()
);
if($decorator instanceof \Closure) {
$decorator($joinClause);
}
}
);
}
}
// endif
/**
* Nelle HasOneOrMany definiamo :
* - PARENT TABLE(SX) : quella dal lato con cardinalita 1
* - CHILD TABLE(DX) : quella dal lato con cardinalita N
*/
elseif ($relation instanceof HasOneOrMany) {
if($sub) {
if(!$previousTableAlias) {
throw new \RuntimeException('$previousTableAlias is required for sub');
}
$sub = false;
$builder
->from($relatedTableAndAlias)
->whereColumn(
$previousTableAlias.'.'. $relation->getParent()->getKeyName(),
$operator ,
$relatedTableAlias . '.' . $relation->getForeignKeyName()
);
} else {
$builder
->$join(
$relatedTableAndAlias,
function(JoinClause $joinClause) use($decorator,$previousTableAlias,$relation,$operator,$relatedTableAlias) {
$joinClause->on(
$previousTableAlias ? $previousTableAlias.'.'. $relation->getParent()->getKeyName() : $relation->getQualifiedParentKeyName(),
$operator ,
$relatedTableAlias . '.' . $relation->getForeignKeyName()
);
if($decorator instanceof \Closure) {
$decorator($joinClause);
}
}
);
}
} // endif
else {
throw new \InvalidArgumentException(
sprintf("Relation $segment of type %s is not supported" , get_class($relation))
);
} // else
/**
* Avanza i puntatori
*/
$currentModel = $relatedModel;
$previousTableAlias = $relatedTableAlias;
$relationIndex--;
} // end foreach <RELATIONs>
return $tableAliases;
}
public function scopeWithJoinLeft(Builder $builder, $relationSegments, $rightAlias=null, $decorators=null, $operator='=') {
return $this->scopeWithJoin($builder,$relationSegments,$rightAlias,$decorators,'leftJoin',$operator);
}
/**
* Crea un alias con un prefisso numerico oppure lo recupera da un array
*
* Se indice non e' presente prende elemento piu prossimo
*
* @param $string $prefix
* @param string|array $alias
* @return array|mixed|null|string
*/
public function makeTableAlias($prefix,$alias,$index)
{
$index -=1 ;
if(is_array($alias)) {
if(isset($alias[$index]))
return $alias[$index];
if($index>0)
return array_last($alias).'_'.($index);
return array_last($alias);
}
else if($index==0) {
if(is_null($alias))
return null;
return $alias;
}
return $prefix.'_'.($index);
}
public function getDecorator($decorators,$index)
{
$index -=1 ;
if(!is_array($decorators)) {
return null;
}
if(isset($decorators[$index]))
return $decorators[$index];
return null;
}
}
@koxu1996
Copy link

Does this support nested relations?

@MatteoOreficeIT
Copy link
Author

MatteoOreficeIT commented Mar 14, 2018

Hi @koxu1996 , if you mean fetching nested/consecutive relations from a model through its and other model relations the answer is YES

For example if we have :

class A extends Model {
    public function relatesB() { return $this->belongsTo( B::class ); }
}

class B extends Model {
    public function relatesC() { return $this->belongsTo( C::class ); }
}

class C extends Model {
    public function relatesD() { return $this->belongsTo( D::class ); }
}

class C extends Model { }

you can do this :

\App\A::query()
    // include every available aliases from different join clause
    ->select('A.*','B.*','C.*','D.*')
    ->joinWith(
        // specify relations join order/path for consecutive / nested relations
        ['relatesB','relatesC','relatesD'],
        // place an alias for every related model, remember in reverse order
        // you can also use only ['D'] and so the other aliases will be generated appendind a number for example D_1,D_2, ..., D_N
        ['D','C','B','A']
     );

Keep in mind that to simulate the eager loading behaviour you can place in the select clause many alias containing in the name a dot , for example :

$query->select('A.field1 as relationB.field1')  /// you will find in result a key [ 'relationB.field1' => 'valueOfField1OnModelB' ]

and after you can group these values with a code like the following :

        $relationValues = [];
        collect($model->getAttributes())->each(function($value,$name)use(&$relationValues,$model){
            if(Str::contains($name,'.')) {
                Arr::set($relationValues,$name,$value);
                unset($model[$name]);
            }
        });
        collect($relationValues)->each(function($value, $name)use(&$model){
           $model[$name] = $value;
        });

The resulting model will have the related model fields grouped by relation name of your choice.

@MatteoOreficeIT
Copy link
Author

MatteoOreficeIT commented Apr 10, 2018

UPDATE: now you can also use withSelect()

@mludi
Copy link

mludi commented May 8, 2018

Thanks for the snippet. Just small things I found while inspecting:

->joinWith(
        // specify relations join order/path for consecutive / nested relations
        ['relatesB','relatesC','relatesD'],
        // place an alias for every related model, remember in reverse order
        // you can also use only ['D'] and so the other aliases will be generated appendind a number for example D_1,D_2, ..., D_N
        ['D','C','B','A']
     );

should be

->withJoin(...

and $randomPrefix = Str::randomStringAlpha(3); is not available, it's just Str::random(3)

https://laravel.com/api/5.6/Illuminate/Support/Str.html#method_random

@MatteoOreficeIT
Copy link
Author

MatteoOreficeIT commented Jun 5, 2019

Thanks @mludi , it was a custom function we added.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment