├── docker-compose.yml ├── src ├── LaravelRelationJoinServiceProvider.php ├── MorphTypes.php ├── Mixins │ ├── RelationJoinQueries.php │ ├── JoinOperations.php │ ├── MergeJoins.php │ └── JoinsRelationships.php ├── EloquentJoinClause.php └── RelationJoinQuery.php ├── LICENSE ├── composer.json └── README.md /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | dev: 3 | build: 4 | context: ./vendor/reedware/sail-lite/runtimes/8.4 5 | dockerfile: Dockerfile 6 | args: 7 | WWWGROUP: '${WWWGROUP}' 8 | image: laravel-relation-joins 9 | environment: 10 | WWWUSER: '${WWWUSER}' 11 | volumes: 12 | - '.:/var/www/html' 13 | -------------------------------------------------------------------------------- /src/LaravelRelationJoinServiceProvider.php: -------------------------------------------------------------------------------- 1 | > 13 | */ 14 | public array $items = []; 15 | 16 | public bool $all = false; 17 | 18 | /** 19 | * Creates a new morph types instance. 20 | * 21 | * @param class-string|array>|true $items 22 | */ 23 | public function __construct(array|string|bool $items) 24 | { 25 | if ($items === true) { 26 | $this->all = true; 27 | } else { 28 | $this->items = (array) $items; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2019-present Tyler Reed 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /src/Mixins/RelationJoinQueries.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | class RelationJoinQueries 19 | { 20 | /** 21 | * Defines the mixin for {@see $relation->getRelationJoinQuery()}. 22 | */ 23 | public function getRelationJoinQuery(): Closure 24 | { 25 | /** 26 | * Adds the constraints for a relationship join. 27 | * 28 | * @param Builder $query 29 | * @param Builder $parentQuery 30 | * @return Builder 31 | */ 32 | return function (Builder $query, Builder $parentQuery, string $type = 'inner', ?string $alias = null): Builder { 33 | /** @var Relation $this */ 34 | return RelationJoinQuery::get($this, $query, $parentQuery, $type, $alias); 35 | }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Mixins/JoinOperations.php: -------------------------------------------------------------------------------- 1 | on()}. 13 | */ 14 | public function on(): Closure 15 | { 16 | /** 17 | * Add an "on" clause to the join. 18 | */ 19 | return function ( 20 | Closure|string $first, 21 | ?string $operator = null, 22 | ?string $second = null, 23 | string $boolean = 'and' 24 | ): Builder { 25 | /** @var Builder $this */ 26 | if ($first instanceof Closure) { 27 | return $this->whereNested($first, $boolean); 28 | } 29 | 30 | return $this->whereColumn($first, $operator, $second, $boolean); 31 | }; 32 | } 33 | 34 | /** 35 | * Defines the mixin for {@see $query->orOn()}. 36 | */ 37 | public function orOn(): Closure 38 | { 39 | /** 40 | * Add an "or on" clause to the join. 41 | */ 42 | return function (Closure|string $first, ?string $operator = null, ?string $second = null): Builder { 43 | /** @var Builder $this */ 44 | return $this->on($first, $operator, $second, 'or'); 45 | }; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reedware/laravel-relation-joins", 3 | "description": "Adds the ability to join on a relationship by name.", 4 | "keywords": [ 5 | "laravel", 6 | "relation", 7 | "join", 8 | "eloquent", 9 | "query" 10 | ], 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Tyler Reed", 15 | "email": "tylernathanreed@gmail.com" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.2", 20 | "illuminate/contracts": "^11.0|^12.0", 21 | "illuminate/database": "^11.0|^12.0", 22 | "illuminate/support": "^11.0|^12.0" 23 | }, 24 | "require-dev": { 25 | "illuminate/container": "^11.0|^12.0", 26 | "larastan/larastan": "^2.9", 27 | "laravel/pint": "^1.5", 28 | "mockery/mockery": "^1.6.6", 29 | "php-coveralls/php-coveralls": "^2.4", 30 | "phpunit/phpunit": "^10.5", 31 | "reedware/sail-lite": "^1.2", 32 | "symfony/var-dumper": "^6.4.4|^7.0" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "Reedware\\LaravelRelationJoins\\": "src/" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "Tests\\": "tests/" 42 | } 43 | }, 44 | "extra": { 45 | "laravel": { 46 | "providers": [ 47 | "Reedware\\LaravelRelationJoins\\LaravelRelationJoinServiceProvider" 48 | ] 49 | } 50 | }, 51 | "minimum-stability": "stable", 52 | "config": { 53 | "sort-packages": true, 54 | "preferred-install": "dist" 55 | }, 56 | "scripts": { 57 | "test:coverage": [ 58 | "@test:suite", 59 | "php-coveralls -v --dry-run" 60 | ], 61 | "test:static": "phpstan", 62 | "test:style": "pint --test", 63 | "test:style-fix": "pint", 64 | "test:suite": "phpunit", 65 | "test": [ 66 | "@test:style", 67 | "@test:static", 68 | "@test:suite", 69 | "@test:coverage" 70 | ] 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Mixins/MergeJoins.php: -------------------------------------------------------------------------------- 1 | mergeJoins()}. 14 | */ 15 | public function mergeJoins(): Closure 16 | { 17 | /** 18 | * Merges an array of join clauses and bindings. 19 | */ 20 | return function (array $joins, array $bindings): void { 21 | /** @var Builder $this */ 22 | $this->joins = array_merge($this->joins ?: [], (array) $joins); 23 | 24 | $this->bindings['join'] = array_values( 25 | array_merge($this->bindings['join'], (array) $bindings) 26 | ); 27 | }; 28 | } 29 | 30 | /** 31 | * Defines the mixin for {@see $query->replaceWhereNestedQueryBuildersWithJoinBuilders()}. 32 | */ 33 | public function replaceWhereNestedQueryBuildersWithJoinBuilders(): Closure 34 | { 35 | /** 36 | * Replaces the query builders in nested "where" clauses with join builders. 37 | */ 38 | return function (Builder $query): void { 39 | /** @var Builder $this */ 40 | $wheres = $query->wheres; 41 | 42 | $wheres = array_map(function ($where) { 43 | if (! isset($where['query'])) { 44 | return $where; 45 | } 46 | 47 | if ($where['type'] == 'Exists' || $where['type'] == 'NotExists') { 48 | return $where; 49 | } 50 | 51 | $this->replaceWhereNestedQueryBuildersWithJoinBuilders($where['query']); 52 | 53 | $joinClause = new JoinClause($where['query'], 'inner', $where['query']->from); 54 | 55 | foreach (array_keys(get_object_vars($where['query'])) as $key) { 56 | $joinClause->{$key} = $where['query']->{$key}; 57 | } 58 | 59 | $where['query'] = $joinClause; 60 | 61 | return $where; 62 | }, $wheres); 63 | 64 | $query->wheres = $wheres; 65 | }; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/EloquentJoinClause.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | public Eloquent $eloquent; 27 | 28 | /** 29 | * Whether or not a method call is being forwarded through eloquent. 30 | */ 31 | protected bool $forwardingCall = false; 32 | 33 | /** 34 | * Create a new join clause instance. 35 | * 36 | * @param TModel $model 37 | */ 38 | public function __construct(JoinClause $parentJoin, Model $model) 39 | { 40 | parent::__construct( 41 | $parentJoin->newParentQuery(), 42 | $parentJoin->type, 43 | $parentJoin->table 44 | ); 45 | 46 | $this->mergeQuery($parentJoin); 47 | 48 | $this->model = $model; 49 | $this->eloquent = $this->newEloquentQuery(); 50 | } 51 | 52 | /** 53 | * Merges the properties of the parent join into this join. 54 | */ 55 | protected function mergeQuery(Builder $query): void 56 | { 57 | $properties = (new ReflectionClass(Builder::class))->getProperties(); 58 | 59 | foreach ($properties as $property) { 60 | if (! $property->isPublic()) { 61 | continue; 62 | } 63 | 64 | $name = $property->getName(); 65 | 66 | $this->{$name} = $query->{$name}; 67 | } 68 | } 69 | 70 | /** 71 | * Apply the scopes to the eloquent builder instance and return it. 72 | */ 73 | public function applyScopes(): static 74 | { 75 | $query = $this->eloquent->applyScopes(); 76 | 77 | $this->mergeQuery($query->getQuery()); 78 | 79 | return $this; 80 | } 81 | 82 | /** 83 | * Returns a new query builder for the model's table. 84 | * 85 | * @return Eloquent 86 | */ 87 | public function newEloquentQuery(): Eloquent 88 | { 89 | return $this->model->registerGlobalScopes( 90 | $this->newModelQuery() 91 | ); 92 | } 93 | 94 | /** 95 | * Returns a new eloquent builder that doesn't have any global scopes or eager loading. 96 | * 97 | * @return Eloquent 98 | */ 99 | public function newModelQuery(): Eloquent 100 | { 101 | return $this->newEloquentBuilder()->setModel($this->model); 102 | } 103 | 104 | /** 105 | * Returns a new eloquent builder for this join clause. 106 | * 107 | * @return Eloquent 108 | */ 109 | public function newEloquentBuilder(): Eloquent 110 | { 111 | /** @var Eloquent */ 112 | return new Eloquent($this); 113 | } 114 | 115 | /** 116 | * Get a new instance of the join clause builder. 117 | */ 118 | public function newQuery(): JoinClause 119 | { 120 | return new JoinClause($this->newParentQuery(), $this->type, $this->table); 121 | } 122 | 123 | /** 124 | * Handle dynamic method calls into the method. 125 | * 126 | * @param string $method 127 | * @param array $parameters 128 | * @return mixed 129 | */ 130 | public function __call($method, $parameters) 131 | { 132 | // If we're already forwarding a call, pass off to the parent method 133 | if ($this->forwardingCall) { 134 | return parent::__call($method, $parameters); 135 | } 136 | 137 | // Otherwise, forward the call to eloquent 138 | $this->forwardingCall = true; 139 | $this->forwardCallTo($this->eloquent, $method, $parameters); 140 | $this->forwardingCall = false; 141 | 142 | return $this; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/RelationJoinQuery.php: -------------------------------------------------------------------------------- 1 | 27 | * @phpstan-type TBelongsTo BelongsTo 28 | * @phpstan-type TBelongsToMany BelongsToMany 29 | * @phpstan-type THasOne HasOne 30 | * @phpstan-type THasMany HasMany 31 | * @phpstan-type TMorphOne MorphOne 32 | * @phpstan-type TMorphMany MorphMany 33 | * @phpstan-type THasOneThrough HasOneThrough 34 | * @phpstan-type THasManyThrough HasManyThrough 35 | * @phpstan-type TMorphToMany MorphToMany 36 | */ 37 | class RelationJoinQuery 38 | { 39 | /** 40 | * Adds the constraints for a relationship join. 41 | * 42 | * @param TRelation $relation 43 | * @param Builder $query 44 | * @param Builder $parentQuery 45 | * @return Builder 46 | */ 47 | public static function get( 48 | Relation $relation, 49 | Builder $query, 50 | Builder $parentQuery, 51 | string $type = 'inner', 52 | ?string $alias = null 53 | ): Builder { 54 | if ($relation instanceof BelongsTo) { 55 | return static::belongsTo($relation, $query, $parentQuery, $type, $alias); 56 | } elseif ($relation instanceof MorphToMany) { 57 | return static::morphToMany($relation, $query, $parentQuery, $type, $alias); 58 | } elseif ($relation instanceof BelongsToMany) { 59 | return static::belongsToMany($relation, $query, $parentQuery, $type, $alias); 60 | } elseif ($relation instanceof HasMany) { 61 | return static::hasOneOrMany($relation, $query, $parentQuery, $type, $alias); 62 | } elseif ($relation instanceof HasOneThrough) { 63 | return static::hasOneOrManyThrough($relation, $query, $parentQuery, $type, $alias); 64 | } elseif ($relation instanceof HasManyThrough) { 65 | return static::hasOneOrManyThrough($relation, $query, $parentQuery, $type, $alias); 66 | } elseif ($relation instanceof HasOne) { 67 | return static::hasOneOrMany($relation, $query, $parentQuery, $type, $alias); 68 | } elseif ($relation instanceof MorphMany) { 69 | return static::morphOneOrMany($relation, $query, $parentQuery, $type, $alias); 70 | } elseif ($relation instanceof MorphOne) { 71 | return static::morphOneOrMany($relation, $query, $parentQuery, $type, $alias); 72 | } 73 | 74 | throw new InvalidArgumentException('Unsupported relation type ['.get_class($relation).'].'); 75 | } 76 | 77 | /** 78 | * Adds the constraints for a belongs to relationship join. 79 | * 80 | * @param TBelongsTo $relation 81 | * @param Builder $query 82 | * @param Builder $parentQuery 83 | * @return Builder 84 | */ 85 | protected static function belongsTo( 86 | BelongsTo $relation, 87 | Builder $query, 88 | Builder $parentQuery, 89 | string $type = 'inner', 90 | ?string $alias = null 91 | ): Builder { 92 | if (is_null($alias) && $query->getQuery()->from == $parentQuery->getQuery()->from) { 93 | $alias = $relation->getRelationCountHash(); 94 | } 95 | 96 | if (! is_null($alias) && $alias != $query->getModel()->getTable()) { 97 | $query->from($query->getModel()->getTable().' as '.$alias); 98 | 99 | $query->getModel()->setTable($alias); 100 | } 101 | 102 | $query->whereColumn( 103 | $relation->getQualifiedOwnerKeyName(), '=', $relation->getQualifiedForeignKeyName() 104 | ); 105 | 106 | return $query; 107 | } 108 | 109 | /** 110 | * Adds the constraints for a belongs to many relationship join. 111 | * 112 | * @param TBelongsToMany $relation 113 | * @param Builder $query 114 | * @param Builder $parentQuery 115 | * @return Builder 116 | */ 117 | protected static function belongsToMany( 118 | BelongsToMany $relation, 119 | Builder $query, 120 | Builder $parentQuery, 121 | string $type = 'inner', 122 | ?string $alias = null 123 | ): Builder { 124 | if (! is_null($alias) && strpos($alias, ',') !== false) { 125 | [$pivotAlias, $farAlias] = explode(',', $alias); 126 | } else { 127 | [$pivotAlias, $farAlias] = [null, $alias]; 128 | } 129 | 130 | if (is_null($farAlias) && $parentQuery->getQuery()->from === $query->getQuery()->from) { 131 | $farAlias = $relation->getRelationCountHash(); 132 | } 133 | 134 | if (! is_null($farAlias) && $farAlias != $relation->getRelated()->getTable()) { 135 | $query->from($relation->getRelated()->getTable().' as '.$farAlias); 136 | 137 | $relation->getRelated()->setTable($farAlias); 138 | } 139 | 140 | if (! is_null($pivotAlias) && $pivotAlias != $relation->getTable()) { 141 | $table = $relation->getTable().' as '.$pivotAlias; 142 | 143 | $on = $pivotAlias; 144 | } else { 145 | $table = $on = $relation->getTable(); 146 | } 147 | 148 | $query->join($table, function ($join) use ($relation, $on) { 149 | $join->on($on.'.'.$relation->getForeignPivotKeyName(), '=', $relation->getQualifiedParentKeyName()); 150 | }, null, null, $type); 151 | 152 | // When a belongs to many relation uses an eloquent model to define the pivot 153 | // in between the two models, we should elevate the join through eloquent 154 | // so that query scopes can be leveraged. This is opt-in functionality. 155 | 156 | if (($using = $relation->getPivotClass()) != Pivot::class) { 157 | $query->getQuery()->joins[0] = new EloquentJoinClause( 158 | $query->getQuery()->joins[0], // @phpstan-ignore offsetAccess.notFound (Join is added above) 159 | (new $using)->setTable($on) 160 | ); 161 | } 162 | 163 | $query->whereColumn( 164 | $relation->getRelated()->qualifyColumn($relation->getRelatedKeyName()), 165 | '=', 166 | $on.'.'.$relation->getRelatedPivotKeyName() 167 | ); 168 | 169 | return $query; 170 | } 171 | 172 | /** 173 | * Adds the constraints for a has one or has many relationship join. 174 | * 175 | * @param THasOne|THasMany|TMorphOne|TMorphMany $relation 176 | * @param Builder $query 177 | * @param Builder $parentQuery 178 | * @return Builder 179 | */ 180 | protected static function hasOneOrMany( 181 | HasOne|HasMany|MorphOne|MorphMany $relation, 182 | Builder $query, 183 | Builder $parentQuery, 184 | string $type = 'inner', 185 | ?string $alias = null 186 | ): Builder { 187 | if (is_null($alias) && $query->getQuery()->from == $parentQuery->getQuery()->from) { 188 | $alias = $relation->getRelationCountHash(); 189 | } 190 | 191 | if (! is_null($alias) && $alias != $query->getModel()->getTable()) { 192 | $query->from($query->getModel()->getTable().' as '.$alias); 193 | 194 | $query->getModel()->setTable($alias); 195 | } 196 | 197 | $query->whereColumn( 198 | $query->qualifyColumn($relation->getForeignKeyName()), '=', $relation->getQualifiedParentKeyName() 199 | ); 200 | 201 | return $query; 202 | } 203 | 204 | /** 205 | * Adds the constraints for a has one through or has many through relationship join. 206 | * 207 | * @param THasOneThrough|THasManyThrough $relation 208 | * @param Builder $query 209 | * @param Builder $parentQuery 210 | * @return Builder 211 | */ 212 | protected static function hasOneOrManyThrough( 213 | HasOneThrough|HasManyThrough $relation, 214 | Builder $query, 215 | Builder $parentQuery, 216 | string $type = 'inner', 217 | ?string $alias = null 218 | ): Builder { 219 | if (! is_null($alias) && strpos($alias, ',') !== false) { 220 | [$throughAlias, $farAlias] = explode(',', $alias); 221 | } else { 222 | [$throughAlias, $farAlias] = [null, $alias]; 223 | } 224 | 225 | if (is_null($farAlias) && $parentQuery->getQuery()->from === $query->getQuery()->from) { 226 | $farAlias = $relation->getRelationCountHash(); 227 | } 228 | 229 | if (is_null($throughAlias) && $parentQuery->getQuery()->from === $relation->getParent()->getTable()) { 230 | $throughAlias = $relation->getRelationCountHash(); 231 | } 232 | 233 | if (! is_null($farAlias) && $farAlias != $query->getModel()->getTable()) { 234 | $query->from($query->getModel()->getTable().' as '.$farAlias); 235 | 236 | $query->getModel()->setTable($farAlias); 237 | } 238 | 239 | if (! is_null($throughAlias) && $throughAlias != $relation->getParent()->getTable()) { 240 | $table = $relation->getParent()->getTable().' as '.$throughAlias; 241 | 242 | $on = $throughAlias; 243 | } else { 244 | $table = $on = $relation->getParent()->getTable(); 245 | } 246 | 247 | $query->join($table, function ($join) use ($relation, $parentQuery, $on) { 248 | $join->on( 249 | $on.'.'.$relation->getFirstKeyName(), 250 | '=', 251 | $parentQuery->qualifyColumn($relation->getLocalKeyName()) 252 | ); 253 | }, null, null, $type); 254 | 255 | // The has one/many through relations use an eloquent model to define the step 256 | // in between the two models. To allow pivot constraints to leverage query 257 | // scopes, we are going to define the query through eloquent instead. 258 | 259 | $query->getQuery()->joins[0] = new EloquentJoinClause( 260 | $query->getQuery()->joins[0], // @phpstan-ignore offsetAccess.notFound (Join added above) 261 | $relation->getParent()->newInstance()->setTable($on) 262 | ); 263 | 264 | $query->whereColumn( 265 | $relation->getQualifiedForeignKeyName(), '=', $on.'.'.$relation->getSecondLocalKeyName() 266 | ); 267 | 268 | return $query; 269 | } 270 | 271 | /** 272 | * Adds the constraints for a morph one or morph many relationship join. 273 | * 274 | * @param TMorphOne|TMorphMany $relation 275 | * @param Builder $query 276 | * @param Builder $parentQuery 277 | * @return Builder 278 | */ 279 | protected static function morphOneOrMany( 280 | MorphOne|MorphMany $relation, 281 | Builder $query, 282 | Builder $parentQuery, 283 | string $type = 'inner', 284 | ?string $alias = null 285 | ): Builder { 286 | if (! is_null($alias) && $alias != $relation->getRelated()->getTable()) { 287 | $query->from($relation->getRelated()->getTable().' as '.$alias); 288 | 289 | $relation->getRelated()->setTable($alias); 290 | } 291 | 292 | return static::hasOneOrMany($relation, $query, $parentQuery, $type, $alias)->where( 293 | $relation->getRelated()->qualifyColumn($relation->getMorphType()), '=', $relation->getMorphClass() 294 | ); 295 | } 296 | 297 | /** 298 | * Adds the constraints for a morph to many relationship join. 299 | * 300 | * @param TMorphToMany $relation 301 | * @param Builder $query 302 | * @param Builder $parentQuery 303 | * @return Builder 304 | */ 305 | protected static function morphToMany( 306 | MorphToMany $relation, 307 | Builder $query, 308 | Builder $parentQuery, 309 | string $type = 'inner', 310 | ?string $alias = null 311 | ): Builder { 312 | if (! is_null($alias) && strpos($alias, ',') !== false) { 313 | [$pivotAlias, $farAlias] = explode(',', $alias); 314 | } else { 315 | [$pivotAlias, $farAlias] = [null, $alias]; 316 | } 317 | 318 | if (is_null($farAlias) && $parentQuery->getQuery()->from === $query->getQuery()->from) { 319 | $farAlias = $relation->getRelationCountHash(); 320 | } 321 | 322 | if (! is_null($farAlias) && $farAlias != $relation->getRelated()->getTable()) { 323 | $query->from($relation->getRelated()->getTable().' as '.$farAlias); 324 | 325 | $relation->getRelated()->setTable($farAlias); 326 | } 327 | 328 | if (! is_null($pivotAlias) && $pivotAlias != $relation->getTable()) { 329 | $table = $relation->getTable().' as '.$pivotAlias; 330 | 331 | $on = $pivotAlias; 332 | } else { 333 | $table = $on = $relation->getTable(); 334 | } 335 | 336 | $query->join($table, function ($join) use ($relation, $on) { 337 | $join->on($on.'.'.$relation->getForeignPivotKeyName(), '=', $relation->getQualifiedParentKeyName()); 338 | 339 | $join->where($on.'.'.$relation->getMorphType(), '=', $relation->getMorphClass()); 340 | }, null, null, $type); 341 | 342 | // When a belongs to many relation uses an eloquent model to define the pivot 343 | // in between the two models, we should elevate the join through eloquent 344 | // so that query scopes can be leveraged. This is opt-in functionality. 345 | 346 | if (($using = $relation->getPivotClass()) != Pivot::class) { 347 | $query->getQuery()->joins[0] = new EloquentJoinClause( 348 | $query->getQuery()->joins[0], // @phpstan-ignore offsetAccess.notFound (Join added above) 349 | (new $using)->setTable($on) 350 | ); 351 | } 352 | 353 | $query->whereColumn( 354 | $relation->getRelated()->qualifyColumn($relation->getRelatedKeyName()), 355 | '=', 356 | $on.'.'.$relation->getRelatedPivotKeyName() 357 | ); 358 | 359 | return $query; 360 | } 361 | } 362 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Relation Joins 2 | 3 | [![Laravel Version](https://img.shields.io/badge/Laravel-11.x%2F12.x-blue)](https://laravel.com/) 4 | [![Tests](https://github.com/tylernathanreed/laravel-relation-joins/actions/workflows/tests.yml/badge.svg)](https://github.com/tylernathanreed/laravel-relation-joins/actions/workflows/tests.yml) 5 | [![Lint](https://github.com/tylernathanreed/laravel-relation-joins/actions/workflows/coding-standards.yml/badge.svg)](https://github.com/tylernathanreed/laravel-relation-joins/actions/workflows/coding-standards.yml) 6 | [![Code Coverage](https://coveralls.io/repos/github/tylernathanreed/laravel-relation-joins/badge.svg?branch=master)](https://coveralls.io/github/tylernathanreed/laravel-relation-joins?branch=master) 7 | [![Static Analysis](https://github.com/tylernathanreed/laravel-relation-joins/actions/workflows/static-analysis.yml/badge.svg)](https://github.com/tylernathanreed/laravel-relation-joins/actions/workflows/static-analysis.yml) 8 | [![Total Downloads](https://poser.pugx.org/reedware/laravel-relation-joins/downloads)](https://packagist.org/packages/reedware/laravel-relation-joins) 9 | 10 | This package adds the ability to join on a relationship by name. 11 | 12 | ## Table of Contents 13 | 14 | - [Introduction](#introduction) 15 | - [Installation](#installation) 16 | - [Versioning](#versioning) 17 | - [Usage](#usage) 18 | - [1. Performing a join via relationship](#joining-basic) 19 | - [2. Joining to nested relationships](#joining-nested) 20 | - [3. Adding join constraints](#joining-constraints) 21 | - [Query Scopes](#joining-constraints-scopes) 22 | - [Soft Deletes](#joining-constraints-soft-deletes) 23 | - [4. Adding pivot constraints](#joining-constraints-pivot) 24 | - [Query Scopes](#joining-constraints-pivot-scopes) 25 | - [Soft Deletes](#joining-constraints-pivot-soft-deletes) 26 | - [5. Adding multiple constraints](#multiple-constraints) 27 | - [Array-Syntax](#multiple-constraints-array) 28 | - [Through-Syntax](#multiple-constraints-through) 29 | - [6. Joining on circular relationships](#joining-circular) 30 | - [7. Aliasing joins](#joining-aliasing) 31 | - [Aliasing Pivot Tables](#joining-aliasing-pivot) 32 | - [8. Morph To Relations](#joining-morph-to) 33 | - [Nested Relationships](#joining-morph-to-nested) 34 | - [9. Anonymous Relations](#joining-anonymous) 35 | - [10. Everything else](#joining-miscellaneous) 36 | - [Conflicts](#conflicts) 37 | - [1. Packages & Alternatives](#packages-and-alternatives) 38 | - [2. Overriding getTable](#get-table) 39 | 40 | 41 | ## Introduction 42 | 43 | This package makes joining a breeze by leveraging the relationships you have already defined. 44 | 45 | Eloquent doesn't offer any tools for joining, so we've been stuck with the base query builder joins. While Eloquent does have the "has" concept for existence, there are still times where you want to return information about the related entities, or aggregate information together. 46 | 47 | Aside from relationships themselves, Eloquent's omission of relationship joins means that you can't leverage several powerful features of Eloquent, such as model scopes and soft deletes. This package aims to correct all of that. 48 | 49 | I've seen other packages out there that try to accomplish a goal similar to this one. I tried to get on board with at least one of them, but they all fell short for a number of reasons. Let me first explain the features of this package, and you might see why this one is better (at least what for what I intend to use it for). 50 | 51 | 52 | ## Installation 53 | 54 | Install this package using Composer: 55 | 56 | ``` 57 | composer require reedware/laravel-relation-joins 58 | ``` 59 | 60 | This package leverages auto-discovery for its service provider. If you have auto discovery disabled for this package, you'll need to manually register the service provider: 61 | ``` 62 | Reedware\LaravelRelationJoins\LaravelRelationJoinServiceProvider::class 63 | ``` 64 | 65 | 66 | ### Versioning 67 | 68 | This package is maintained with the latest version of Laravel in mind, but support follows Laravel's [Support Policy](https://laravel.com/docs/master/releases#support-policy). 69 | 70 | | Package | Laravel | PHP | 71 | | :-----: | :---------: | :--------: | 72 | | 8.x | 11.x - 12.x | 8.2 - 8.4+ | 73 | | 7.x | 10.x - 11.x | 8.2 - 8.4+ | 74 | | 6.x | 10.x - 11.x | 8.1 - 8.3+ | 75 | | 5.x | 9.x - 10.x | 8.0 - 8.2+ | 76 | | 4.x | 8.x - 10.x | 7.3 - 8.0+ | 77 | | 3.x | 7.x - 9.x | 7.2 - 8.0+ | 78 | | 2.x | 6.x - 8.x | 7.2 - 8.0+ | 79 | | 1.x | 5.5 - 8.x | 7.1 - 8.0+ | 80 | 81 | 82 | ## Usage 83 | 84 | 85 | ### 1. Performing a join via relationship 86 | 87 | This is the entire point of this package, so here's a basic example: 88 | 89 | ```php 90 | User::query()->joinRelation('posts'); 91 | ``` 92 | 93 | This will apply a join from the `User` model through the `posts` relation, leveraging any query scopes (such as soft deletes) automatically. 94 | 95 | You can perform joins over all relationship types, including polymorphic relationships. Additionally, you can perform the other types of joins, using a syntax similar to the base query builder: 96 | 97 | ```php 98 | User::query()->leftJoinRelation('posts'); 99 | User::query()->rightJoinRelation('posts'); 100 | User::query()->crossJoinRelation('posts'); 101 | ``` 102 | 103 | 104 | ### 2. Joining to nested relationships 105 | 106 | One of the shining abilities of being able to join through relationships shows up when you have to navigate through a nested web of relationships. When trying to join on a relation through another relation, you can use the "dot" syntax, similar to how the "has" and "with" concepts work: 107 | 108 | ```php 109 | User::query()->joinRelation('posts.comments'); 110 | ``` 111 | 112 | 113 | ### 3. Adding join constraints 114 | 115 | This is honestly where I felt a lot of the existing solutions were lacking. They either created custom "where" clauses, or limited the query to only supporting certain types of "where" clauses. With this package, there are no known restrictions, and the means of adding the constraints is very intuitive: 116 | 117 | ```php 118 | User::query()->joinRelation('posts', function ($join) { 119 | $join->where('posts.created_at', '>=', '2019-01-01'); 120 | }); 121 | ``` 122 | 123 | This will tack on the specific constraints to the already provided relationship constraints, making this really easy to use. 124 | 125 | 126 | #### Query Scopes 127 | 128 | One of the most powerful features offered by this package is the ability to leverage query scopes within joins. Calling a query scope on the `$join` parameter is essentially the same as calling it on the related model. 129 | 130 | ```php 131 | // Using the "active" query scope on the "Post" model 132 | User::query()->joinRelation('posts', function ($join) { 133 | $join->active(); 134 | }); 135 | ``` 136 | 137 | 138 | #### Soft Deletes 139 | 140 | It can be frustrating to respecify soft deletes in all of your joins, when the model itself already knows how to do this. When using relation joins, soft deletes are automatically handled! Additionally, you can still leverage the query scopes that ship with soft deletes: 141 | 142 | ```php 143 | // Disabling soft deletes for only the "Post" model 144 | User::query()->joinRelation('posts', function ($join) { 145 | $join->withTrashed(); 146 | }); 147 | ``` 148 | 149 | 150 | ### 4. Adding pivot constraints 151 | 152 | Constraints aren't limited to just the join table itself. Certain relationships require multiple joins, which introduces additional tables. You can still apply constraints on these joins directly. To be clear, this is intended for "Has One/Many Through" and "Belongs/Morph to Many" relations. 153 | 154 | ```php 155 | // Adding pivot ("role_user") constraints for a "Belongs to Many" relation 156 | User::query()->joinRelation('roles', function ($join, $pivot) { 157 | $pivot->where('domain', '=', 'web'); 158 | }); 159 | ``` 160 | 161 | ```php 162 | // Adding pivot ("users") constraints for a "Has Many Through" relation 163 | Country::query()->joinRelation('posts', function ($join, $through) { 164 | $through->where('is_admin', '=', true); 165 | }); 166 | ``` 167 | 168 | This will tack on the specific constraints to the intermediate table, as well as any constraints you provide to the far (`$join`) table. 169 | 170 | 171 | #### Query Scopes 172 | 173 | When the intermediate table is represented by a model, you can leverage query scopes for that model as well. This is default behavior for the "Has One/Many Through" relations. For the "Belongs/Morph To Many" relations, you'll need to leverage the `->using(Model::class)` method to obtain this benefit. 174 | 175 | ```php 176 | // Using a query scope for the intermediate "RoleUser" pivot in a "Belongs to Many" relation 177 | User::query()->joinRelation('roles', function ($join, $pivot) { 178 | $pivot->web(); 179 | }); 180 | ``` 181 | 182 | 183 | #### Soft Deletes 184 | 185 | Similar to regular join constraints, soft deletes on the pivot are automatically accounted for. Additionally, you can still leverage the query scopes that ship with soft deletes: 186 | 187 | ```php 188 | // Disabling soft deletes for the intermediate "User" model 189 | Country::query()->joinRelation('posts', function ($join, $through) { 190 | $through->withTrashed(); 191 | }); 192 | ``` 193 | 194 | When using a "Belongs/Morph to Many" relationship, a pivot model must be specified for soft deletes to be considered. 195 | 196 | 197 | ### 5. Adding multiple constraints 198 | 199 | There are times where you want to tack on clauses for intermediate joins. This can get a bit tricky in some other packages (by trying to automatically deduce whether or not to apply a join, or by not handling this situation at all). This package introduces two solutions, where both have value in different situations. 200 | 201 | 202 | #### Array-Syntax 203 | 204 | The first approach to handling multiple constraints is using an array syntax. This approach allows you to define all of your nested joins and constraints together: 205 | 206 | ```php 207 | User::query()->joinRelation('posts.comments', [ 208 | function ($join) { $join->where('is_active', '=', 1); }, 209 | function ($join) { $join->where('comments.title', 'like', '%looking for something%'); } 210 | ]); 211 | ``` 212 | 213 | The array syntax supports both sequential and associative variants: 214 | 215 | ```php 216 | // Sequential 217 | User::query()->joinRelation('posts.comments', [ 218 | null, 219 | function ($join) { $join->where('comments.title', 'like', '%looking for something%'); } 220 | ]); 221 | 222 | // Associative 223 | User::query()->joinRelation('posts.comments', [ 224 | 'comments' => function ($join) { $join->where('comments.title', 'like', '%looking for something%'); } 225 | ]); 226 | ``` 227 | 228 | If you're using aliases, the associate array syntax refers to the fully qualified relation: 229 | ```php 230 | User::query()->joinRelation('posts as articles.comments as threads', [ 231 | 'posts as articles' => function ($join) { $join->where('is_active', '=', 1); }, 232 | 'comments as threads' => function ($join) { $join->where('threads.title', 'like', '%looking for something%'); } 233 | ]); 234 | ``` 235 | 236 | 237 | #### Through-Syntax 238 | 239 | The second approach to handling multiple constraints is using a through syntax. This approach allows us to define your joins and constraints individually: 240 | 241 | ```php 242 | User::query()->joinRelation('posts', function ($join) { 243 | $join->where('is_active', '=', 1); 244 | })->joinThroughRelation('posts.comments', function ($join) { 245 | $join->where('comments.title', 'like', '%looking for something%'); 246 | }); 247 | ``` 248 | 249 | The "through" concept here allows you to define a nested join using "dot" syntax, where only the final relation is actually constrained, and the prior relations are assumed to already be handled. So in this case, the `joinThroughRelation` method will only apply the `comments` relation join, but it will do so as if it came from the `Post` model. 250 | 251 | 252 | ### 6. Joining on circular relationships 253 | 254 | This package also supports joining on circular relations, and handles it the same way the "has" concept does: 255 | 256 | ```php 257 | public function employees() 258 | { 259 | return $this->hasMany(static::class, 'manager_id', 'id'); 260 | } 261 | 262 | User::query()->joinRelation('employees'); 263 | 264 | // SQL: select * from "users" inner join "users" as "laravel_reserved_0" on "laravel_reserved_0"."manager_id" = "users"."id" 265 | ``` 266 | 267 | Now clearly, if you're wanting to apply constraints on the `employees` relation, having this sort of naming convention isn't desirable. This brings me to the next feature: 268 | 269 | 270 | ### 7. Aliasing joins 271 | 272 | You could alias the above example like so: 273 | 274 | ```php 275 | User::query()->joinRelation('employees as employees'); 276 | 277 | // SQL: select * from "users" inner join "users" as "employees" on "employees"."manager_id" = "users"."id" 278 | ``` 279 | 280 | The join doesn't have to be circular to support aliasing. Here's an example: 281 | 282 | ```php 283 | User::query()->joinRelation('posts as articles'); 284 | 285 | // SQL: select * from "users" inner join "posts" as "articles" on "articles"."user_id" = "users"."id" 286 | ``` 287 | 288 | This also works for nested relations: 289 | 290 | ```php 291 | User::query()->joinRelation('posts as articles.comments as feedback'); 292 | 293 | // SQL: select * from "users" inner join "posts" as "articles" on "articles"."user_id" = "users"."id" inner join "comments" as "feedback" on "feedback"."post_id" = "articles"."id" 294 | ``` 295 | 296 | 297 | #### Aliasing Pivot Tables 298 | For relations that require multiple tables (i.e. BelongsToMany, HasManyThrough, etc.), the alias will apply to the far/non-pivot table. If you need to alias the pivot/through table, you can use a double-alias: 299 | 300 | ```php 301 | public function roles() 302 | { 303 | return $this->belongsToMany(EloquentRoleModelStub::class, 'role_user', 'user_id', 'role_id'); 304 | } 305 | 306 | User::query()->joinRelation('roles as users_roles,roles'); 307 | // SQL: select * from "users" inner join "role_user" as "users_roles" on "users_roles"."user_id" = "users"."id" inner join "roles" on "roles"."id" = "users_roles"."role_id" 308 | 309 | User::query()->joinRelation('roles as users_roles,positions'); 310 | // SQL: select * from "users" inner join "role_user" as "position_user" on "position_user"."user_id" = "users"."id" inner join "roles" as "positions" on "positions"."id" = "position_user"."role_id" 311 | ``` 312 | 313 | 314 | ### 8. Morph To Relations 315 | 316 | The `MorphTo` relation has the quirk of not knowing which table that needs to be joined into, as there could be several. Since only one table is supported, you'll have to provide which morph type you want to use: 317 | 318 | ```php 319 | Image::query()->joinMorphRelation('imageable', Post::class); 320 | // SQL: select * from "images" inner join "posts" on "posts"."id" = "images"."imageable_id" and "images"."imageable_type" = ? 321 | ``` 322 | 323 | As before, other join types are also supported: 324 | 325 | ```php 326 | Image::query()->leftJoinMorphRelation('imageable', Post::class); 327 | Image::query()->rightJoinMorphRelation('imageable', Post::class); 328 | Image::query()->crossJoinMorphRelation('imageable', Post::class); 329 | ``` 330 | 331 | After the morph type has been specified, the traditional parameters follow: 332 | 333 | ```php 334 | // Constraints 335 | Image::query()->joinMorphRelation('imageable', Post::class, function ($join) { 336 | $join->where('posts.created_at', '>=', '2019-01-01'); 337 | }); 338 | 339 | // Query Scopes 340 | Image::query()->joinMorphRelation('imageable', Post::class, function ($join) { 341 | $join->active(); 342 | }); 343 | 344 | // Disabling soft deletes 345 | Image::query()->joinMorphRelation('imageable', Post::class, function ($join) { 346 | $join->withTrashed(); 347 | }); 348 | ``` 349 | 350 | 351 | #### Nested Relationships 352 | 353 | When previously covering the `MorphTo` relation, the relation itself was singularly called out. However, in a nested scenario, the `MorphTo` relation could be anywhere. Fortunately, this still doesn't change the syntax: 354 | 355 | ```php 356 | User::query()->joinMorphRelation('uploadedImages.imageable', Post::class); 357 | // SQL: select * from "users" inner join "images" on "images.uploaded_by_id" = "users.id" inner join "posts" on "posts"."id" = "images"."imageable_id" and "images"."imageable_type" = ? 358 | ``` 359 | 360 | Since multiple relationships could be specified, multiple `MorphTo` relations could be in play. When this happens, you'll need to provide the morph type for each relation: 361 | 362 | ```php 363 | User::query()->joinMorphRelation('uploadedFiles.link.imageable', [Image::class, Post::class]); 364 | // SQL: select * from "users" inner join "files" on "files"."uploaded_by_id" = "users"."id" inner join "images" on "images"."id" = "files"."link_id" and "files"."link_type" = ? inner join "users" on "users"."id" = "images"."imageable_id" and "images"."imageable_type" = ? 365 | ``` 366 | 367 | In the scenario above, the morph types are used for the `MorphTo` relations in the order they appear. 368 | 369 | 370 | ### 9. Anonymous Joins 371 | 372 | In rare circumstances, you may find yourself in a situation where you don't want to define a relationship on a model, but you still want to join on it as if it existed. You can do this by passing in the relationship itself: 373 | 374 | ```php 375 | $relation = Relation::noConstraints(function () { 376 | return (new User) 377 | ->belongsTo(Country::class, 'country_name', 'name'); 378 | }); 379 | 380 | User::query()->joinRelation($relation); 381 | // SQL: select * from "users" inner join "countries" on "countries"."name" = "users"."country_name" 382 | ``` 383 | 384 | #### Aliasing Anonymous Joins 385 | 386 | Since the relation is no longer a string, you instead have to provide your alias as an array: 387 | 388 | ```php 389 | $relation = Relation::noConstraints(function () { 390 | return (new User) 391 | ->belongsTo(Country::class, 'kingdom_name', 'name'); 392 | }); 393 | 394 | User::query()->joinRelation([$relation, 'kingdoms']); 395 | // SQL: select * from "users" inner join "countries" as "kingdoms" on "kingdoms"."name" = "users"."kingdom_name" 396 | ``` 397 | 398 | 399 | ### 10. Everything else 400 | 401 | Everything else you would need for joins: aggregates, grouping, ordering, selecting, etc. all go through the already established query builder, where none of that was changed. Meaning you could easily do something like this: 402 | 403 | ```php 404 | User::query()->joinRelation('licenses')->groupBy('users.id')->orderBy('users.id')->select('users.id')->selectRaw('sum(licenses.price) as revenue'); 405 | ``` 406 | 407 | Personally, I see myself using this a ton in Laravel Nova (specifically lenses), but I've been needing queries like this for years in countless scenarios. 408 | 409 | Joins are something that nearly every developer will eventually use, so having Eloquent natively support joining over relations would be fantastic. However, since that doesn't come out of the box, you'll have to install this package instead. My goal with this package is to mirror the Laravel "feel" of coding, where complex implementations (such as joining over named relations) is simple to use and easy to understand. 410 | 411 | 412 | ## Conflicts 413 | 414 | Like any package that modifies or extends query behavior, there are bound to be conflicts with other packages that attempt to do something similar. I've architected this package with high compatability in mind. That said, there are some basic assumptions, which I feel are fair, that this package requires. Anything that violates these assumptions is at risk of conflicting with this package. 415 | 416 | Here are the approaches and requirements of this package: 417 | - This package does not override the default query builder, and instead leverages its [Macroable](https://github.com/laravel/framework/blob/12.x/src/Illuminate/Macroable/Traits/Macroable.php) behavior 418 | - This package does not require any changes to the Model instance, and even supports Models using their own Eloquent builder instances 419 | - This package requires the Eloquent Relations to respect their property types and return types 420 | - This package requires the `setTable` method on models to impact subsequent calls to `getTable` and `qualifyColumn` 421 | 422 | In short, if you have a package or implementation that's breaking return types, or preventing calls to `setTable` from having any affect, there's likely going to be some amount of conflict. 423 | 424 | 425 | ### 1. Packages & Alternatives 426 | 427 | Here are the known packages that conflict with this one, and why: 428 | 429 | [awobaz/compoships](https://github.com/topclaudy/compoships) 430 | - Properties of relations, like `BelongsTo::$foreignKey` are expected to be a `string` by the framework, but this package injects `array` values. 431 | - Use [reedware/laravel-composite-relations](https://github.com/tylernathanreed/laravel-composite-relations) instead, which defines new relations, rather than overriding existing ones, and respects return types 432 | 433 | 434 | 435 | ### 2. Overriding getTable 436 | 437 | When a join alias is used, the underlying implementation swaps out the table name of the model with the aliased counterpart so that any qualified constraints are bound against the aliased table, rather than the original one. This relies on the ability to set a model's table during runtime. If `getTable` returns a constant, you won't be able to use aliasing. You can still override the `getTable` function, you just have to support the behavior of `setTable`. 438 | 439 | Here's an example that **would conflict** with this package: 440 | 441 | ```php 442 | public function getTable() 443 | { 444 | return 'my_table'; 445 | } 446 | ``` 447 | 448 | Here's an example that **would not conflict** with this package: 449 | 450 | ```php 451 | public function getTable() 452 | { 453 | return $this->table ?? 'my_table'; 454 | } 455 | ``` 456 | -------------------------------------------------------------------------------- /src/Mixins/JoinsRelationships.php: -------------------------------------------------------------------------------- 1 | joinRelation()}. 23 | */ 24 | public function joinRelation(): Closure 25 | { 26 | /** 27 | * Add a relationship join condition to the query. 28 | * 29 | * @param Relation|string|array $relation 30 | * @param Closure|array|null $callback 31 | * @param MorphTypes|array>|class-string|true $morphTypes 32 | */ 33 | return function ( 34 | Relation|string|array $relation, 35 | Closure|array|null $callback = null, 36 | string $type = 'inner', 37 | bool $through = false, 38 | ?Builder $relatedQuery = null, 39 | MorphTypes|array|string|bool $morphTypes = true 40 | ): Builder { 41 | /** @var Builder $this */ 42 | if (! $morphTypes instanceof MorphTypes) { 43 | $morphTypes = new MorphTypes($morphTypes); // @phpstan-ignore-line 44 | } 45 | 46 | if (is_string($relation)) { 47 | if (strpos($relation, '.') !== false) { 48 | return $this->joinNestedRelation($relation, $callback, $type, $through, $morphTypes); 49 | } 50 | 51 | if (($parts = preg_split('/\s+as\s+/i', $relation)) && count($parts) >= 2) { 52 | [$relationName, $alias] = $parts; 53 | } else { 54 | $relationName = $relation; 55 | } 56 | 57 | $relation = ($relatedQuery ?: $this)->getRelationWithoutConstraints($relationName); 58 | 59 | if (! $relation instanceof Relation) { 60 | throw new LogicException(sprintf( 61 | '%s::%s must return a relationship instance.', 62 | get_class($this->getModel()), 63 | $relationName) 64 | ); 65 | } 66 | } elseif (is_array($relation)) { 67 | [$relation, $alias] = $relation; 68 | } 69 | 70 | if ($relation instanceof MorphTo) { 71 | $relation = $this->getBelongsToJoinRelation($relation, $morphTypes, $relatedQuery ?: $this); 72 | } 73 | 74 | $joinQuery = $relation->getRelationJoinQuery( 75 | $relation->getRelated()->newQuery(), $relatedQuery ?: $this, $type, $alias ?? null 76 | ); 77 | 78 | // If we're simply passing through a relation, then we want to advance the relation 79 | // without actually applying any joins. Presumably the developer has already used 80 | // a modified version of this join, and they don't want to do it all over again. 81 | if ($through) { 82 | return $this->applyJoinScopes($joinQuery); 83 | } 84 | 85 | // Next we will call any given callback as an "anonymous" scope so they can get the 86 | // proper logical grouping of the where clauses if needed by this Eloquent query 87 | // builder. Then, we will be ready to finalize and return this query instance. 88 | 89 | if (is_array($callback)) { 90 | $callback = reset($callback); 91 | } 92 | 93 | if ($callback) { 94 | $this->callJoinScope($joinQuery, $callback); 95 | } else { 96 | $this->applyJoinScopes($joinQuery); 97 | } 98 | 99 | $this->addJoinRelationWhere( 100 | $joinQuery, $relation, $type 101 | ); 102 | 103 | return ! is_null($relatedQuery) ? $joinQuery : $this; 104 | 105 | }; 106 | } 107 | 108 | /** 109 | * Defines the mixin for {@see $query->joinNestedRelation()}. 110 | */ 111 | public function joinNestedRelation(): Closure 112 | { 113 | /** 114 | * Add nested relationship join conditions to the query. 115 | * 116 | * @param Closure|array|null $callbacks 117 | */ 118 | return function ( 119 | string $relations, 120 | Closure|array|null $callbacks, 121 | string $type, 122 | bool $through, 123 | MorphTypes $morphTypes 124 | ): Builder { 125 | /** @var Builder $this */ 126 | $relations = explode('.', $relations); 127 | 128 | $relatedQuery = $this; 129 | 130 | $callbacks = is_array($callbacks) 131 | ? ( 132 | Arr::isAssoc($callbacks) 133 | ? $callbacks 134 | : array_combine($relations, $callbacks) 135 | ) 136 | : [end($relations) => $callbacks]; 137 | 138 | while (count($relations) > 0) { 139 | $relation = array_shift($relations); 140 | $callback = $callbacks[$relation] ?? null; 141 | $useThrough = count($relations) > 0 && $through; 142 | 143 | $relatedQuery = $this->joinRelation( 144 | $relation, 145 | $callback, 146 | $type, 147 | $useThrough, 148 | $relatedQuery, 149 | $morphTypes 150 | ); 151 | } 152 | 153 | return $this; 154 | }; 155 | } 156 | 157 | /** 158 | * Defines the mixin for {@see $query->applyJoinScopes()}. 159 | */ 160 | public function applyJoinScopes(): Closure 161 | { 162 | /** 163 | * Applies the eloquent scopes to the specified query. 164 | */ 165 | return function (Builder $joinQuery): Builder { 166 | /** @var Builder $this */ 167 | $joins = $joinQuery->getQuery()->joins ?: []; 168 | 169 | foreach ($joins as $join) { 170 | if ($join instanceof EloquentJoinClause) { 171 | $join->applyScopes(); 172 | } 173 | } 174 | 175 | return $joinQuery; 176 | }; 177 | } 178 | 179 | /** 180 | * Defines the mixin for {@see $query->callJoinScope()}. 181 | */ 182 | public function callJoinScope(): Closure 183 | { 184 | /** 185 | * Calls the provided callback on the join query. 186 | */ 187 | return function (Builder $joinQuery, Closure $callback): void { 188 | /** @var Builder $this */ 189 | $joins = $joinQuery->getQuery()->joins ?: []; 190 | 191 | array_unshift($joins, $joinQuery); 192 | 193 | $queries = array_map(function ($join) { 194 | return $join instanceof JoinClause ? $join : $join->getQuery(); 195 | }, $joins); 196 | 197 | // We will keep track of how many wheres are on each query before running the 198 | // scope so that we can properly group the added scope constraints in each 199 | // query as their own isolated nested where statement and avoid issues. 200 | 201 | $originalWhereCounts = array_map(function ($query) { 202 | return count($query->wheres ?: []); 203 | }, $queries); 204 | 205 | $callback(...$joins); 206 | 207 | $this->applyJoinScopes($joinQuery); 208 | 209 | foreach ($originalWhereCounts as $index => $count) { 210 | if (count($queries[$index]->wheres ?: []) > $count) { 211 | $joinQuery->addNewWheresWithinGroup($queries[$index], $count); 212 | } 213 | } 214 | 215 | // Once the constraints have been applied, we'll need to shuffle arounds the 216 | // bindings so that the base query receives everything. We will apply all 217 | // of the bindings from the subsequent joins onto the first query. 218 | 219 | $joinQuery->getQuery()->bindings['join'] = []; 220 | 221 | array_shift($queries); 222 | 223 | foreach ($queries as $query) { 224 | $joinQuery->addBinding($query->getBindings(), 'join'); 225 | } 226 | }; 227 | } 228 | 229 | /** 230 | * Defines the mixin for {@see $query->addJoinRelationWhere()}. 231 | */ 232 | public function addJoinRelationWhere(): Closure 233 | { 234 | /** 235 | * Add the "join relation" condition where clause to the query. 236 | */ 237 | return function (Builder $joinQuery, Relation $relation, string $type): Builder { 238 | /** @var Builder $this */ 239 | $joinQuery->mergeConstraintsFrom($relation->getQuery()); 240 | 241 | $baseJoinQuery = $joinQuery->toBase(); 242 | 243 | if (! empty($baseJoinQuery->joins)) { 244 | $this->mergeJoins($baseJoinQuery->joins, $baseJoinQuery->bindings['join']); 245 | } 246 | 247 | $this->join($baseJoinQuery->from, function ($join) use ($baseJoinQuery) { 248 | 249 | // There's an issue with mixing query builder where clauses 250 | // with join builder where clauses. To solve for this, we 251 | // have to recursively replace the nested where queries. 252 | 253 | $this->replaceWhereNestedQueryBuildersWithJoinBuilders($baseJoinQuery); 254 | 255 | $join->mergeWheres($baseJoinQuery->wheres, $baseJoinQuery->bindings['where']); 256 | 257 | }, null, null, $type); 258 | 259 | return $this; 260 | }; 261 | } 262 | 263 | /** 264 | * Defines the mixin for {@see $query->getBelongsToJoinRelation()}. 265 | */ 266 | public function getBelongsToJoinRelation(): Closure 267 | { 268 | /** 269 | * Returns the belongs to relation for the next morph. 270 | */ 271 | return function (MorphTo $relation, MorphTypes $morphTypes, Builder $relatedQuery): BelongsTo { 272 | /** @var Builder $this */ 273 | 274 | // When it comes to joining across morph types, we can really only support 275 | // a single type. However, when we're provided multiple types, we will 276 | // instead use these one at a time and pass the information along. 277 | 278 | if ($morphTypes->all) { 279 | $types = $relatedQuery->model 280 | ->newQuery() 281 | ->distinct() 282 | ->pluck($relation->getMorphType()) 283 | ->filter() 284 | ->all(); 285 | 286 | $types = array_unique(array_map(function ($morphType) { 287 | return Relation::getMorphedModel($morphType) ?? $morphType; 288 | }, $types)); 289 | 290 | if (count($types) > 1) { 291 | throw new RuntimeException('joinMorphRelation() does not support multiple morph types.'); 292 | } 293 | 294 | $morphTypes->items = $types; 295 | } 296 | 297 | // We're going to handle the morph type join as a belongs to relationship 298 | // that has the type itself constrained. This allows us to join into a 299 | // singular table, which bypasses the typical headache of morphs. 300 | 301 | if (count($morphTypes->items) == 0) { 302 | throw new RuntimeException('joinMorphRelation() requires at least one morph type.'); 303 | } 304 | 305 | $morphType = array_shift($morphTypes->items); 306 | 307 | $belongsTo = $relatedQuery->getBelongsToRelation($relation, $morphType); 308 | 309 | $belongsTo->where( 310 | $relatedQuery->qualifyColumn($relation->getMorphType()), 311 | '=', 312 | (new $morphType)->getMorphClass() 313 | ); 314 | 315 | return $belongsTo; 316 | }; 317 | } 318 | 319 | /** 320 | * Defines the mixin for {@see $query->leftJoinRelation()}. 321 | */ 322 | public function leftJoinRelation(): Closure 323 | { 324 | /** 325 | * Add a relationship left join condition to the query. 326 | * 327 | * @param Closure|array|null $callback 328 | */ 329 | return function (string $relation, Closure|array|null $callback = null, bool $through = false): Builder { 330 | /** @var Builder $this */ 331 | return $this->joinRelation($relation, $callback, 'left', $through); 332 | }; 333 | } 334 | 335 | /** 336 | * Defines the mixin for {@see $query->rightJoinRelation()}. 337 | */ 338 | public function rightJoinRelation(): Closure 339 | { 340 | /** 341 | * Add a relationship right join condition to the query. 342 | * 343 | * @param Closure|array|null $callback 344 | */ 345 | return function (string $relation, Closure|array|null $callback = null, bool $through = false): Builder { 346 | /** @var Builder $this */ 347 | return $this->joinRelation($relation, $callback, 'right', $through); 348 | }; 349 | } 350 | 351 | /** 352 | * Defines the mixin for {@see $query->crossJoinRelation()}. 353 | */ 354 | public function crossJoinRelation(): Closure 355 | { 356 | /** 357 | * Add a relationship cross join condition to the query. 358 | * 359 | * @param Closure|array|null $callback 360 | */ 361 | return function (string $relation, Closure|array|null $callback = null, bool $through = false): Builder { 362 | /** @var Builder $this */ 363 | return $this->joinRelation($relation, $callback, 'cross', $through); 364 | }; 365 | } 366 | 367 | /** 368 | * Defines the mixin for {@see $query->joinThroughRelation()}. 369 | */ 370 | public function joinThroughRelation(): Closure 371 | { 372 | /** 373 | * Add a relationship join condition through a related model to the query. 374 | * 375 | * @param Closure|array|null $callback 376 | */ 377 | return function (string $relation, Closure|array|null $callback = null, string $type = 'inner'): Builder { 378 | /** @var Builder $this */ 379 | return $this->joinRelation($relation, $callback, $type, true); 380 | }; 381 | } 382 | 383 | /** 384 | * Defines the mixin for {@see $query->leftJoinThroughRelation()}. 385 | */ 386 | public function leftJoinThroughRelation(): Closure 387 | { 388 | /** 389 | * Add a relationship left join condition through a related model to the query. 390 | * 391 | * @param Closure|array|null $callback 392 | */ 393 | return function (string $relation, Closure|array|null $callback = null): Builder { 394 | /** @var Builder $this */ 395 | return $this->joinRelation($relation, $callback, 'left', true); 396 | }; 397 | } 398 | 399 | /** 400 | * Defines the mixin for {@see $query->rightJoinThroughRelation()}. 401 | */ 402 | public function rightJoinThroughRelation(): Closure 403 | { 404 | /** 405 | * Add a relationship right join condition through a related model to the query. 406 | * 407 | * @param Closure|array|null $callback 408 | */ 409 | return function (string $relation, Closure|array|null $callback = null): Builder { 410 | /** @var Builder $this */ 411 | return $this->joinRelation($relation, $callback, 'right', true); 412 | }; 413 | } 414 | 415 | /** 416 | * Defines the mixin for {@see $query->crossJoinThroughRelation()}. 417 | */ 418 | public function crossJoinThroughRelation(): Closure 419 | { 420 | /** 421 | * Add a relationship cross join condition through a related model to the query. 422 | * 423 | * @param Closure|array|null $callback 424 | */ 425 | return function (string $relation, Closure|array|null $callback = null): Builder { 426 | /** @var Builder $this */ 427 | return $this->joinRelation($relation, $callback, 'cross', true); 428 | }; 429 | } 430 | 431 | /** 432 | * Defines the mixin for {@see $query->joinMorphRelation()}. 433 | */ 434 | public function joinMorphRelation(): Closure 435 | { 436 | /** 437 | * Add a morph to relationship join condition to the query. 438 | * 439 | * @param string|array $relation 440 | * @param array>|class-string|true $morphTypes 441 | * @param Closure|array|null $callback 442 | */ 443 | return function ( 444 | string|array $relation, 445 | array|string|bool $morphTypes = true, 446 | Closure|array|null $callback = null, 447 | string $type = 'inner', 448 | bool $through = false, 449 | ?Builder $relatedQuery = null 450 | ): Builder { 451 | /** @var Builder $this */ 452 | return $this->joinRelation($relation, $callback, $type, $through, $relatedQuery, $morphTypes); 453 | }; 454 | } 455 | 456 | /** 457 | * Defines the mixin for {@see $query->leftJoinMorphRelation()}. 458 | */ 459 | public function leftJoinMorphRelation(): Closure 460 | { 461 | /** 462 | * Add a morph to relationship left join condition to the query. 463 | * 464 | * @param string|array $relation 465 | * @param array|string $morphTypes 466 | * @param Closure|array|null $callback 467 | */ 468 | return function ( 469 | string|array $relation, 470 | array|string $morphTypes = ['*'], 471 | Closure|array|null $callback = null, 472 | bool $through = false 473 | ): Builder { 474 | /** @var Builder $this */ 475 | return $this->joinRelation($relation, $callback, 'left', $through, null, $morphTypes); 476 | }; 477 | } 478 | 479 | /** 480 | * Defines the mixin for {@see $query->rightJoinMorphRelation()}. 481 | */ 482 | public function rightJoinMorphRelation(): Closure 483 | { 484 | /** 485 | * Add a morph to relationship right join condition to the query. 486 | * 487 | * @param string|array $relation 488 | * @param array|string $morphTypes 489 | * @param Closure|array|null $callback 490 | */ 491 | return function ( 492 | string|array $relation, 493 | array|string $morphTypes = ['*'], 494 | Closure|array|null $callback = null, 495 | bool $through = false 496 | ): Builder { 497 | /** @var Builder $this */ 498 | return $this->joinRelation($relation, $callback, 'right', $through, null, $morphTypes); 499 | }; 500 | } 501 | 502 | /** 503 | * Defines the mixin for {@see $query->crossJoinMorphRelation()}. 504 | */ 505 | public function crossJoinMorphRelation(): Closure 506 | { 507 | /** 508 | * Add a morph to relationship cross join condition to the query. 509 | * 510 | * @param string|array $relation 511 | * @param array|string $morphTypes 512 | * @param Closure|array|null $callback 513 | */ 514 | return function ( 515 | string|array $relation, 516 | array|string $morphTypes = ['*'], 517 | Closure|array|null $callback = null, 518 | bool $through = false 519 | ): Builder { 520 | /** @var Builder $this */ 521 | return $this->joinRelation($relation, $callback, 'cross', $through, null, $morphTypes); 522 | }; 523 | } 524 | 525 | /** 526 | * Defines the mixin for {@see $query->joinThroughMorphRelation()}. 527 | */ 528 | public function joinThroughMorphRelation(): Closure 529 | { 530 | /** 531 | * Add a morph to relationship join condition through a related model to the query. 532 | * 533 | * @param string|array $relation 534 | * @param array|string $morphTypes 535 | * @param Closure|array|null $callback 536 | */ 537 | return function ( 538 | string|array $relation, 539 | array|string $morphTypes = ['*'], 540 | Closure|array|null $callback = null, 541 | string $type = 'inner' 542 | ): Builder { 543 | /** @var Builder $this */ 544 | return $this->joinRelation($relation, $callback, $type, true, null, $morphTypes); 545 | }; 546 | } 547 | 548 | /** 549 | * Defines the mixin for {@see $query->leftJoinThroughMorphRelation()}. 550 | */ 551 | public function leftJoinThroughMorphRelation(): Closure 552 | { 553 | /** 554 | * Add a morph to relationship left join condition through a related model to the query. 555 | * 556 | * @param string|array $relation 557 | * @param array|string $morphTypes 558 | * @param Closure|array|null $callback 559 | */ 560 | return function ( 561 | string|array $relation, 562 | array|string $morphTypes = ['*'], 563 | Closure|array|null $callback = null 564 | ): Builder { 565 | /** @var Builder $this */ 566 | return $this->joinRelation($relation, $callback, 'left', true, null, $morphTypes); 567 | }; 568 | } 569 | 570 | /** 571 | * Defines the mixin for {@see $query->rightJoinThroughMorphRelation()}. 572 | */ 573 | public function rightJoinThroughMorphRelation(): Closure 574 | { 575 | /** 576 | * Add a morph to relationship right join condition through a related model to the query. 577 | * 578 | * @param string|array $relation 579 | * @param array|string $morphTypes 580 | * @param Closure|array|null $callback 581 | */ 582 | return function ( 583 | string|array $relation, 584 | array|string $morphTypes = ['*'], 585 | Closure|array|null $callback = null 586 | ): Builder { 587 | /** @var Builder $this */ 588 | return $this->joinRelation($relation, $callback, 'right', true, null, $morphTypes); 589 | }; 590 | } 591 | 592 | /** 593 | * Defines the mixin for {@see $query->crossJoinThroughMorphRelation()}. 594 | */ 595 | public function crossJoinThroughMorphRelation(): Closure 596 | { 597 | /** 598 | * Add a morph to relationship cross join condition through a related model to the query. 599 | * 600 | * @param string|array $relation 601 | * @param array|string $morphTypes 602 | * @param Closure|array|null $callback 603 | */ 604 | return function ( 605 | string|array $relation, 606 | array|string $morphTypes = ['*'], 607 | Closure|array|null $callback = null 608 | ): Builder { 609 | /** @var Builder $this */ 610 | return $this->joinRelation($relation, $callback, 'cross', true, null, $morphTypes); 611 | }; 612 | } 613 | } 614 | --------------------------------------------------------------------------------