├── LICENSE ├── README.md ├── composer.json └── src ├── DatabaseServiceProvider.php ├── Eloquent ├── Builder.php ├── HasMergedRelationships.php └── Relations │ └── MergedRelation.php ├── Facades └── Schema.php ├── IdeHelper └── MergedRelationsHook.php ├── IdeHelperServiceProvider.php └── Schema └── Builders ├── CreatesMergeViews.php ├── MariaDbBuilder.php ├── MySqlBuilder.php ├── PostgresBuilder.php ├── SQLiteBuilder.php └── SqlServerBuilder.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Jonas Staudenmeir 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Merged Relations 2 | 3 | [![CI](https://github.com/staudenmeir/laravel-merged-relations/actions/workflows/ci.yml/badge.svg)](https://github.com/staudenmeir/laravel-merged-relations/actions/workflows/ci.yml?query=branch%3Amain) 4 | [![Code Coverage](https://codecov.io/gh/staudenmeir/laravel-merged-relations/graph/badge.svg?token=ZRYGD44QVX)](https://codecov.io/gh/staudenmeir/laravel-merged-relations) 5 | [![PHPStan](https://img.shields.io/badge/PHPStan-level%2010-brightgreen.svg?style=flat)](https://github.com/staudenmeir/laravel-merged-relations/actions/workflows/static-analysis.yml?query=branch%3Amain) 6 | [![Latest Stable Version](https://poser.pugx.org/staudenmeir/laravel-merged-relations/v/stable)](https://packagist.org/packages/staudenmeir/laravel-merged-relations) 7 | [![Total Downloads](https://poser.pugx.org/staudenmeir/laravel-merged-relations/downloads)](https://packagist.org/packages/staudenmeir/laravel-merged-relations/stats) 8 | [![License](https://poser.pugx.org/staudenmeir/laravel-merged-relations/license)](https://github.com/staudenmeir/laravel-merged-relations/blob/main/LICENSE) 9 | 10 | This Laravel Eloquent extension allows merging multiple relationships using SQL views. 11 | The relationships can target the same or different related models. 12 | 13 | Supports Laravel 5.5+. 14 | 15 | ## Installation 16 | 17 | composer require staudenmeir/laravel-merged-relations:"^1.0" 18 | 19 | Use this command if you are in PowerShell on Windows (e.g. in VS Code): 20 | 21 | composer require staudenmeir/laravel-merged-relations:"^^^^1.0" 22 | 23 | ## Versions 24 | 25 | | Laravel | Package | 26 | |:--------|:--------| 27 | | 12.x | 1.10 | 28 | | 11.x | 1.9 | 29 | | 10.x | 1.6 | 30 | | 9.x | 1.5 | 31 | | 8.x | 1.4 | 32 | | 7.x | 1.3 | 33 | | 6.x | 1.2 | 34 | | 5.8 | 1.1 | 35 | | 5.5–5.7 | 1.0 | 36 | 37 | ## Usage 38 | 39 | - [Use Cases](#use-cases) 40 | - [Step 1: Creating Views](#step-1-creating-views) 41 | - [Step 2: Defining Relationships](#step-2-defining-relationships) 42 | - [Pivot Table Data](#pivot-table-data) 43 | - [Limitations](#limitations) 44 | - [Testing](#testing) 45 | 46 | ### Use Cases 47 | 48 | Use the package to merge multiple polymorphic relationships: 49 | 50 | ```php 51 | class Tag extends Model 52 | { 53 | public function allTaggables() 54 | { 55 | // TODO 56 | } 57 | 58 | public function posts() 59 | { 60 | return $this->morphedByMany(Post::class, 'taggable'); 61 | } 62 | 63 | public function videos() 64 | { 65 | return $this->morphedByMany(Video::class, 'taggable'); 66 | } 67 | } 68 | ``` 69 | 70 | Or use it to merge relationships with different depths: 71 | 72 | ```php 73 | class User extends Model 74 | { 75 | public function allComments() 76 | { 77 | // TODO 78 | } 79 | 80 | public function comments() 81 | { 82 | return $this->hasMany(Comment::class); 83 | } 84 | 85 | public function postComments() 86 | { 87 | return $this->hasManyThrough(Comment::class, Post::class); 88 | } 89 | } 90 | ``` 91 | 92 | ### Step 1: Creating Views 93 | 94 | Before you can define the new relationship, you need to create the merge view in a migration: 95 | 96 | ```php 97 | use Staudenmeir\LaravelMergedRelations\Facades\Schema; 98 | 99 | Schema::createMergeView( 100 | 'all_taggables', 101 | [(new Tag)->posts(), (new Tag)->videos()] 102 | ); 103 | ``` 104 | 105 | By default, the view doesn't remove duplicates. Use `createMergeViewWithoutDuplicates()` to get unique results: 106 | 107 | ```php 108 | use Staudenmeir\LaravelMergedRelations\Facades\Schema; 109 | 110 | Schema::createMergeViewWithoutDuplicates( 111 | 'all_comments', 112 | [(new User)->comments(), (new User)->postComments()] 113 | ); 114 | ``` 115 | 116 | You can also replace an existing view: 117 | 118 | ```php 119 | use Staudenmeir\LaravelMergedRelations\Facades\Schema; 120 | 121 | Schema::createOrReplaceMergeView( 122 | 'all_comments', 123 | [(new User)->comments(), (new User)->postComments()] 124 | ); 125 | ``` 126 | 127 | The package includes [staudenmeir/laravel-migration-views](https://github.com/staudenmeir/laravel-migration-views). You 128 | can use its methods to rename and drop views: 129 | 130 | ```php 131 | use Staudenmeir\LaravelMergedRelations\Facades\Schema; 132 | 133 | Schema::renameView('all_comments', 'user_comments'); 134 | 135 | Schema::dropView('all_comments'); 136 | ``` 137 | 138 | If you are using `php artisan migrate:fresh`, you can drop all views with `--drop-views`. 139 | 140 | ### Step 2: Defining Relationships 141 | 142 | With the view created, you can define the merged relationship. 143 | 144 | Use the `HasMergedRelationships` trait in your model and provide the view name: 145 | 146 | ```php 147 | class Tag extends Model 148 | { 149 | use \Staudenmeir\LaravelMergedRelations\Eloquent\HasMergedRelationships; 150 | 151 | public function allTaggables(): \Staudenmeir\LaravelMergedRelations\Eloquent\Relations\MergedRelation 152 | { 153 | return $this->mergedRelation('all_taggables'); 154 | } 155 | } 156 | ``` 157 | 158 | If all original relationships target the same related model, you can use `mergedRelationWithModel()`. This allows you to 159 | access local scopes and use methods like `whereHas()` or `withCount()`: 160 | 161 | ```php 162 | class User extends Model 163 | { 164 | use \Staudenmeir\LaravelMergedRelations\Eloquent\HasMergedRelationships; 165 | 166 | public function allComments(): \Staudenmeir\LaravelMergedRelations\Eloquent\Relations\MergedRelation 167 | { 168 | return $this->mergedRelationWithModel(Comment::class, 'all_comments'); 169 | } 170 | } 171 | ``` 172 | 173 | You can use the merged relationship like any other relationship: 174 | 175 | ```php 176 | $taggables = Tag::find($id)->allTaggables()->latest()->paginate(); 177 | 178 | $users = User::with('allComments')->get(); 179 | ``` 180 | 181 | ### Pivot Table Data 182 | 183 | You can retrieve additional pivot columns if your merge view consists of many-to-many relationships. 184 | 185 | Add the desired pivot columns to _all_ relationships: 186 | 187 | ```php 188 | use Staudenmeir\LaravelMergedRelations\Facades\Schema; 189 | 190 | Schema::createMergeView( 191 | 'all_taggables', 192 | [ 193 | (new Tag)->posts()->withPivot('tagged_at'), 194 | (new Tag)->videos()->withPivot('tagged_at'), 195 | ] 196 | ); 197 | 198 | $taggables = Tag::find($id)->allTaggables; 199 | 200 | foreach ($taggables as $taggable) { 201 | dump($taggable->pivot->tagged_at); 202 | } 203 | ``` 204 | 205 | ### Limitations 206 | 207 | In the original relationships, it's currently not possible to limit the selected columns or apply `withCount()`. 208 | 209 | In the merged relationships, it's not possible to remove global scopes like `SoftDeletes`. They can only be removed in 210 | the original relationships. 211 | 212 | ### Testing 213 | 214 | If you use PHPUnit or a similar tool to run tests, add this property to your base test class to ensure that database 215 | views are dropped when the test database is cleaned up: 216 | 217 | ```php 218 | protected bool $dropViews = true; 219 | ``` 220 | 221 | ## Contributing 222 | 223 | Please see [CONTRIBUTING](.github/CONTRIBUTING.md) and [CODE OF CONDUCT](.github/CODE_OF_CONDUCT.md) for details. 224 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "staudenmeir/laravel-merged-relations", 3 | "description": "Merged Laravel Eloquent relationships", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Jonas Staudenmeir", 8 | "email": "mail@jonas-staudenmeir.de" 9 | } 10 | ], 11 | "require": { 12 | "php": "^8.2", 13 | "illuminate/database": "^12.0", 14 | "staudenmeir/laravel-migration-views": "^1.11" 15 | }, 16 | "require-dev": { 17 | "barryvdh/laravel-ide-helper": "^3.0", 18 | "laravel/framework": "^12.0", 19 | "mockery/mockery": "^1.6", 20 | "orchestra/testbench-core": "^10.0", 21 | "phpstan/phpstan": "^2.0", 22 | "phpunit/phpunit": "^11.0", 23 | "staudenmeir/eloquent-has-many-deep": "^1.21" 24 | }, 25 | "autoload": { 26 | "psr-4": { 27 | "Staudenmeir\\LaravelMergedRelations\\": "src/" 28 | } 29 | }, 30 | "autoload-dev": { 31 | "psr-4": { 32 | "Tests\\": "tests/" 33 | } 34 | }, 35 | "config": { 36 | "sort-packages": true 37 | }, 38 | "extra": { 39 | "laravel": { 40 | "providers": [ 41 | "Staudenmeir\\LaravelMergedRelations\\DatabaseServiceProvider", 42 | "Staudenmeir\\LaravelMergedRelations\\IdeHelperServiceProvider" 43 | ] 44 | } 45 | }, 46 | "minimum-stability": "dev", 47 | "prefer-stable": true 48 | } 49 | -------------------------------------------------------------------------------- /src/DatabaseServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->bind(Schema::class, function (Application $app) { 19 | /** @var \Illuminate\Database\DatabaseManager $db */ 20 | $db = $app->make('db'); 21 | 22 | return Schema::getSchemaBuilder( 23 | $db->connection() 24 | ); 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Eloquent/Builder.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class Builder extends Base 15 | { 16 | /** 17 | * Get the hydrated models without eager loading. 18 | * 19 | * @param list|string $columns 20 | * @return array 21 | */ 22 | public function getModels($columns = ['*']) 23 | { 24 | /** @var list, laravel_placeholders: string}> $items */ 25 | $items = $this->query->get($columns)->all(); 26 | 27 | $models = []; 28 | 29 | foreach ($items as $item) { 30 | /** @var class-string $class */ 31 | $class = Relation::getMorphedModel($item->laravel_model) ?? $item->laravel_model; 32 | 33 | $unset = ['laravel_model', 'laravel_placeholders']; 34 | 35 | if ($item->laravel_placeholders) { 36 | array_push($unset, ...explode(',', $item->laravel_placeholders)); 37 | } 38 | 39 | foreach ($unset as $key) { 40 | unset($item->$key); 41 | } 42 | 43 | $models[] = (new $class())->newQuery()->hydrate([$item])[0]; 44 | } 45 | 46 | return $models; 47 | } 48 | 49 | /** @inheritDoc */ 50 | public function eagerLoadRelations(array $models) 51 | { 52 | collect($models)->groupBy(function ($model) { 53 | return get_class($model); 54 | })->each(function ($models) { 55 | $model = $models[0]; 56 | 57 | /** @var string $with */ 58 | $with = $model->getAttribute('laravel_with'); 59 | 60 | /** @var array $relations */ 61 | $relations = array_merge( 62 | $this->eagerLoad, 63 | !empty($model->laravel_with) ? explode(',', $with) : [] 64 | ); 65 | 66 | (new Collection($models))->load($relations); 67 | }); 68 | 69 | foreach ($models as $model) { 70 | unset($model->laravel_with); 71 | } 72 | 73 | return $models; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Eloquent/HasMergedRelationships.php: -------------------------------------------------------------------------------- 1 | mergedRelationWithModel(static::class, $view, $localKey); 24 | } 25 | 26 | /** 27 | * Define a merged relationship with model. 28 | * 29 | * @param string $related 30 | * @param string $view 31 | * @param string|null $localKey 32 | * @return \Staudenmeir\LaravelMergedRelations\Eloquent\Relations\MergedRelation 33 | */ 34 | public function mergedRelationWithModel($related, $view, $localKey = null) 35 | { 36 | $instance = $this->newRelatedInstance($related)->setTable($view); 37 | 38 | $query = (new Builder($instance->getConnection()->query()))->setModel($instance); 39 | 40 | $localKey = $localKey ?: $this->getKeyName(); 41 | 42 | return $this->newMergedRelation($query, $this, $view.'.laravel_foreign_key', $localKey); 43 | } 44 | 45 | /** 46 | * Instantiate a new MergedRelation relationship. 47 | * 48 | * @param \Illuminate\Database\Eloquent\Builder $query 49 | * @param \Illuminate\Database\Eloquent\Model $parent 50 | * @param string $foreignKey 51 | * @param string $localKey 52 | * @return \Staudenmeir\LaravelMergedRelations\Eloquent\Relations\MergedRelation 53 | */ 54 | protected function newMergedRelation(EloquentBuilder $query, Model $parent, $foreignKey, $localKey) 55 | { 56 | return new MergedRelation($query, $parent, $foreignKey, $localKey); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Eloquent/Relations/MergedRelation.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class MergedRelation extends HasMany 16 | { 17 | /** 18 | * Get the results of the relationship. 19 | * 20 | * @return mixed 21 | */ 22 | public function getResults() 23 | { 24 | $results = !is_null($this->getParentKey()) 25 | ? $this->get() 26 | : $this->related->newCollection(); 27 | 28 | foreach ($results as $result) { 29 | unset($result->laravel_foreign_key); 30 | } 31 | 32 | return $results; 33 | } 34 | 35 | /** 36 | * Execute the query as a "select" statement. 37 | * 38 | * @param list|string $columns 39 | * @return \Illuminate\Database\Eloquent\Collection 40 | */ 41 | public function get($columns = ['*']) 42 | { 43 | $builder = $this->prepareQueryBuilder($columns); 44 | 45 | $models = $builder->getModels(); 46 | 47 | if (count($models) > 0) { 48 | $models = $builder->eagerLoadRelations($models); 49 | } 50 | 51 | $this->hydratePivotRelations($models); 52 | 53 | return $this->related->newCollection($models); 54 | } 55 | 56 | /** 57 | * Execute the query and get the first related model. 58 | * 59 | * @param list|string $columns 60 | * @return TRelatedModel|null 61 | */ 62 | public function first($columns = ['*']) 63 | { 64 | return $this->take(1)->get($columns)->first(); 65 | } 66 | 67 | /** 68 | * Get a paginator for the "select" statement. 69 | * 70 | * @param int|\Closure|null $perPage 71 | * @param list|string $columns 72 | * @param string $pageName 73 | * @param int|null $page 74 | * @param int|null|\Closure $total 75 | * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 76 | * 77 | * @throws \InvalidArgumentException 78 | */ 79 | public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null, $total = null) 80 | { 81 | $this->query->addSelect( 82 | $this->shouldSelect((array) $columns) 83 | ); 84 | 85 | /** @var \Illuminate\Contracts\Pagination\LengthAwarePaginator $paginator */ 86 | $paginator = $this->query->paginate($perPage, $columns, $pageName, $page, $total); 87 | 88 | $this->hydratePivotRelations( 89 | $paginator->items() 90 | ); 91 | 92 | return $paginator; 93 | } 94 | 95 | /** 96 | * Prepare the query builder for query execution. 97 | * 98 | * @param list|string $columns 99 | * @return \Illuminate\Database\Eloquent\Builder 100 | */ 101 | protected function prepareQueryBuilder($columns = ['*']) 102 | { 103 | $builder = $this->query->applyScopes(); 104 | 105 | $columns = $builder->getQuery()->columns ? [] : (array) $columns; 106 | 107 | $builder->addSelect( 108 | $this->shouldSelect($columns) 109 | ); 110 | 111 | return $builder; 112 | } 113 | 114 | /** 115 | * Get the select columns for the relation query. 116 | * 117 | * @param list $columns 118 | * @return list 119 | */ 120 | protected function shouldSelect(array $columns = ['*']) 121 | { 122 | if ($columns === ['*']) { 123 | return $columns; 124 | } 125 | 126 | return array_merge( 127 | $columns, 128 | ['laravel_foreign_key', 'laravel_model', 'laravel_placeholders', 'laravel_with'] 129 | ); 130 | } 131 | 132 | /** 133 | * Hydrate the pivot table relationships on the models. 134 | * 135 | * @param array $models 136 | * @return void 137 | */ 138 | protected function hydratePivotRelations(array $models): void 139 | { 140 | if (!$models) { 141 | return; 142 | } 143 | 144 | $pivotTables = $this->getPivotTables($models); 145 | 146 | if (!$pivotTables) { 147 | return; 148 | } 149 | 150 | foreach ($models as $model) { 151 | $attributes = $model->getAttributes(); 152 | 153 | foreach ($pivotTables as $accessor => $table) { 154 | $pivotAttributes = []; 155 | 156 | foreach ($table['columns'] as $column) { 157 | $key = "__{$table['table']}__{$accessor}__$column"; 158 | 159 | $pivotAttributes[$column] = $attributes[$key]; 160 | 161 | unset($model->$key); 162 | } 163 | 164 | $relation = Pivot::fromAttributes($model, $pivotAttributes, $table['table'], true); 165 | 166 | $model->setRelation($accessor, $relation); 167 | } 168 | } 169 | } 170 | 171 | /** 172 | * Get the pivot tables from the models. 173 | * 174 | * @param array $models 175 | * @return array, table: string}> 176 | */ 177 | protected function getPivotTables(array $models): array 178 | { 179 | $tables = []; 180 | 181 | foreach (array_keys($models[0]->getAttributes()) as $key) { 182 | if (str_starts_with($key, '__')) { 183 | [, $table, $accessor, $column] = explode('__', $key); 184 | 185 | if (isset($tables[$accessor])) { 186 | $tables[$accessor]['columns'][] = $column; 187 | } else { 188 | $tables[$accessor] = [ 189 | 'columns' => [$column], 190 | 'table' => $table, 191 | ]; 192 | } 193 | } 194 | } 195 | 196 | return $tables; 197 | } 198 | 199 | /** @inheritDoc */ 200 | public function match(array $models, Collection $results, $relation) 201 | { 202 | $models = parent::match($models, $results, $relation); 203 | 204 | foreach ($results as $result) { 205 | unset($result->laravel_foreign_key); 206 | } 207 | 208 | return $models; 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/Facades/Schema.php: -------------------------------------------------------------------------------- 1 | > $relations, bool $duplicates = true, bool $orReplace = false) 21 | * @method static void createMergeViewWithoutDuplicates(string $name, non-empty-list<\Illuminate\Database\Eloquent\Relations\Relation<*, *, *>> $relations) 22 | * @method static void createOrReplaceMergeView(string $name, non-empty-list<\Illuminate\Database\Eloquent\Relations\Relation<*, *, *>> $relations, bool $duplicates = true) 23 | * @method static void createOrReplaceMergeViewWithoutDuplicates(string $name, non-empty-list<\Illuminate\Database\Eloquent\Relations\Relation<*, *, *>> $relations) 24 | * 25 | * @mixin \Staudenmeir\LaravelMigrationViews\Facades\Schema 26 | */ 27 | class Schema extends Facade 28 | { 29 | /** 30 | * Get the registered name of the component. 31 | * 32 | * @return string 33 | */ 34 | protected static function getFacadeAccessor() 35 | { 36 | return static::class; 37 | } 38 | 39 | /** 40 | * Get a schema builder instance for a connection. 41 | * 42 | * @param string $name 43 | * @return \Illuminate\Database\Schema\Builder 44 | */ 45 | public static function connection($name) 46 | { 47 | /** @var array{db: \Illuminate\Database\DatabaseManager} $app */ 48 | $app = static::$app; 49 | 50 | return static::getSchemaBuilder( 51 | $app['db']->connection($name) 52 | ); 53 | } 54 | 55 | /** 56 | * Get the schema builder. 57 | * 58 | * @param \Illuminate\Database\Connection $connection 59 | * @return \Illuminate\Database\Schema\Builder 60 | */ 61 | public static function getSchemaBuilder(Connection $connection) 62 | { 63 | return match ($connection->getDriverName()) { 64 | 'mysql' => new MySqlBuilder( 65 | $connection->setSchemaGrammar( 66 | new MySqlGrammar($connection) 67 | ) 68 | ), 69 | 'mariadb' => new MariaDbBuilder( 70 | $connection->setSchemaGrammar( 71 | new MariaDbGrammar($connection) 72 | ) 73 | ), 74 | 'pgsql' => new PostgresBuilder( 75 | $connection->setSchemaGrammar( 76 | new PostgresGrammar($connection) 77 | ) 78 | ), 79 | 'sqlite' => new SQLiteBuilder( 80 | $connection->setSchemaGrammar( 81 | new SQLiteGrammar($connection) 82 | ) 83 | ), 84 | 'sqlsrv' => new SqlServerBuilder( 85 | $connection->setSchemaGrammar( 86 | new SqlServerGrammar($connection) 87 | ) 88 | ), 89 | default => throw new RuntimeException('This database is not supported.'), // @codeCoverageIgnore 90 | }; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/IdeHelper/MergedRelationsHook.php: -------------------------------------------------------------------------------- 1 | getMethods(ReflectionMethod::IS_PUBLIC); 28 | 29 | foreach ($methods as $method) { 30 | if ($method->isAbstract() || $method->isStatic() || !$method->isPublic() 31 | || $method->getNumberOfParameters() > 0 || $method->getDeclaringClass()->getName() === Model::class) { 32 | continue; 33 | } 34 | 35 | if ($method->getReturnType() instanceof ReflectionNamedType 36 | && $method->getReturnType()->getName() === MergedRelation::class) { 37 | /** @var \Illuminate\Database\Eloquent\Relations\Relation<*, *, *> $relationship */ 38 | $relationship = $method->invoke($model); 39 | 40 | $this->addRelationship($command, $method, $relationship); 41 | } 42 | } 43 | } 44 | 45 | /** 46 | * @param \Illuminate\Database\Eloquent\Relations\Relation<*, *, *> $relationship 47 | */ 48 | protected function addRelationship(ModelsCommand $command, ReflectionMethod $method, Relation $relationship): void 49 | { 50 | $type = '\\' . Collection::class . '|\\' . $relationship->getRelated()::class . '[]'; 51 | 52 | $command->setProperty( 53 | $method->getName(), 54 | $type, 55 | true, 56 | false 57 | ); 58 | 59 | $command->setProperty( 60 | Str::snake($method->getName()) . '_count', 61 | 'int', 62 | true, 63 | false, 64 | null, 65 | true 66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/IdeHelperServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->get('config'); 21 | 22 | $config->set( 23 | 'ide-helper.model_hooks', 24 | array_merge( 25 | [MergedRelationsHook::class], 26 | $config->array('ide-helper.model_hooks', []) 27 | ) 28 | ); 29 | 30 | $this->app->alias(ModelsCommand::class, static::ModelsCommandAlias); 31 | } 32 | 33 | /** 34 | * @return list 35 | */ 36 | public function provides(): array 37 | { 38 | return [ 39 | static::ModelsCommandAlias 40 | ]; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Schema/Builders/CreatesMergeViews.php: -------------------------------------------------------------------------------- 1 | > $relations 21 | * @param bool $duplicates 22 | * @param bool $orReplace 23 | * @return void 24 | */ 25 | public function createMergeView($name, array $relations, $duplicates = true, $orReplace = false) 26 | { 27 | $this->removeConstraints($relations); 28 | 29 | $union = $duplicates ? 'unionAll' : 'union'; 30 | 31 | $query = $this->getQuery($relations, $union); 32 | 33 | $this->createView($name, $query, null, $orReplace); 34 | } 35 | 36 | /** 37 | * Create a view that merges relationships without duplicates. 38 | * 39 | * @param string $name 40 | * @param non-empty-list<\Illuminate\Database\Eloquent\Relations\Relation<*, *, *>> $relations 41 | * @return void 42 | */ 43 | public function createMergeViewWithoutDuplicates($name, array $relations) 44 | { 45 | $this->createMergeView($name, $relations, false); 46 | } 47 | 48 | /** 49 | * Create a view that merges relationships or replace an existing one. 50 | * 51 | * @param string $name 52 | * @param non-empty-list<\Illuminate\Database\Eloquent\Relations\Relation<*, *, *>> $relations 53 | * @param bool $duplicates 54 | * @return void 55 | */ 56 | public function createOrReplaceMergeView($name, array $relations, $duplicates = true) 57 | { 58 | $this->createMergeView($name, $relations, $duplicates, true); 59 | } 60 | 61 | /** 62 | * Create a view that merges relationships or replace an existing one without duplicates. 63 | * 64 | * @param string $name 65 | * @param non-empty-list<\Illuminate\Database\Eloquent\Relations\Relation<*, *, *>> $relations 66 | * @return void 67 | */ 68 | public function createOrReplaceMergeViewWithoutDuplicates($name, array $relations) 69 | { 70 | $this->createOrReplaceMergeView($name, $relations, false); 71 | } 72 | 73 | /** 74 | * Remove the foreign key constraints from the relationships. 75 | * 76 | * @param non-empty-list<\Illuminate\Database\Eloquent\Relations\Relation<*, *, *>> $relations 77 | * @return void 78 | */ 79 | protected function removeConstraints(array $relations) 80 | { 81 | foreach ($relations as $relation) { 82 | $foreignKey = $this->getOriginalForeignKey($relation); 83 | 84 | $relation->getQuery()->getQuery()->wheres = collect($relation->getQuery()->getQuery()->wheres) 85 | ->reject(function ($where) use ($foreignKey) { 86 | /** @var array{column: string} $where */ 87 | 88 | return $where['column'] === $foreignKey; 89 | })->values()->all(); 90 | } 91 | } 92 | 93 | /** 94 | * Get the foreign key of the original relationship. 95 | * 96 | * @param \Illuminate\Database\Eloquent\Relations\Relation<*, *, *> $relation 97 | * @return string 98 | */ 99 | protected function getOriginalForeignKey(Relation $relation) 100 | { 101 | if ($relation instanceof BelongsTo) { 102 | return $relation->getQualifiedOwnerKeyName(); 103 | } 104 | 105 | if ($relation instanceof BelongsToMany) { 106 | return $relation->getQualifiedForeignPivotKeyName(); 107 | } 108 | 109 | if ($relation instanceof HasOneOrManyThrough) { 110 | return $relation->getQualifiedFirstKeyName(); 111 | } 112 | 113 | if ($relation instanceof HasOneOrMany) { 114 | return $relation->getQualifiedForeignKeyName(); 115 | } 116 | 117 | throw new RuntimeException('This type of relationship is not supported.'); // @codeCoverageIgnore 118 | } 119 | 120 | /** 121 | * Get the merge query. 122 | * 123 | * @param non-empty-list<\Illuminate\Database\Eloquent\Relations\Relation<*, *, *>> $relations 124 | * @param string $union 125 | * @return \Illuminate\Database\Eloquent\Builder<*> 126 | */ 127 | protected function getQuery(array $relations, $union) 128 | { 129 | $grammar = $this->connection->getQueryGrammar(); 130 | 131 | $pdo = $this->connection->getPdo(); 132 | 133 | $columns = $this->getRelationshipColumns($relations); 134 | 135 | $pivotTables = $this->getPivotTables($relations); 136 | 137 | $allColumns = array_unique(array_merge(...array_values($columns))); 138 | 139 | $query = null; 140 | 141 | foreach ($relations as $relation) { 142 | $relationQuery = $relation->getQuery(); 143 | 144 | /** @var string $from */ 145 | $from = $relationQuery->getQuery()->from; 146 | 147 | $foreignKey = $this->getMergedForeignKey($relation); 148 | 149 | $model = $relation->getRelated()->getMorphClass(); 150 | 151 | $placeholders = []; 152 | 153 | foreach ($allColumns as $column) { 154 | if (in_array($column, $columns[$from])) { 155 | $relationQuery->addSelect($from.'.'.$column); 156 | } else { 157 | $relationQuery->selectRaw('null as '.$grammar->wrap($column)); 158 | 159 | $placeholders[] = $column; 160 | } 161 | } 162 | 163 | foreach ($pivotTables as $pivotTable) { 164 | foreach ($pivotTable['columns'] as $column) { 165 | $alias = "__{$pivotTable['table']}__{$pivotTable['accessor']}__$column"; 166 | 167 | $relationQuery->addSelect("{$pivotTable['table']}.$column as $alias"); 168 | } 169 | } 170 | 171 | $with = array_keys($relationQuery->getEagerLoads()); 172 | 173 | $relationQuery->selectRaw($grammar->wrap($foreignKey).' as laravel_foreign_key') 174 | ->selectRaw($pdo->quote($model).' as laravel_model') 175 | ->selectRaw($pdo->quote(implode(',', $placeholders)).' as laravel_placeholders') 176 | ->selectRaw($pdo->quote(implode(',', $with)).' as laravel_with'); 177 | 178 | $this->addRelationQueryConstraints($relation); 179 | 180 | if (!$query) { 181 | $query = $relationQuery; 182 | } else { 183 | $query->$union($relationQuery); 184 | } 185 | } 186 | 187 | return $query; 188 | } 189 | 190 | /** 191 | * Get the columns of all relationship tables. 192 | * 193 | * @param non-empty-list<\Illuminate\Database\Eloquent\Relations\Relation<*, *, *>> $relations 194 | * @return array> 195 | */ 196 | protected function getRelationshipColumns(array $relations) 197 | { 198 | $columns = []; 199 | 200 | foreach ($relations as $relation) { 201 | /** @var string $table */ 202 | $table = $relation->getQuery()->getQuery()->from; 203 | 204 | if (!isset($columns[$table])) { 205 | /** @var list $listing */ 206 | $listing = $relation->getRelated()->getConnection()->getSchemaBuilder()->getColumnListing($table); 207 | 208 | $columns[$table] = $listing; 209 | } 210 | } 211 | 212 | return $columns; 213 | } 214 | 215 | /** 216 | * Get the pivot tables that are requested by all relationships. 217 | * 218 | * @param non-empty-list<\Illuminate\Database\Eloquent\Relations\Relation<*, *, *>> $relations 219 | * @return list, table: string}> 220 | */ 221 | protected function getPivotTables(array $relations): array 222 | { 223 | $tables = []; 224 | 225 | foreach ($relations as $i => $relation) { 226 | if ($relation instanceof BelongsToMany) { 227 | /** @var array $pivotColumns */ 228 | $pivotColumns = $relation->getPivotColumns(); 229 | 230 | if ($pivotColumns) { 231 | $tables[$i][] = [ 232 | 'accessor' => $relation->getPivotAccessor(), 233 | 'columns' => $pivotColumns, 234 | 'table' => $relation->getTable(), 235 | ]; 236 | } 237 | } elseif($relation instanceof HasManyDeep || $relation instanceof HasOneDeep) { 238 | $intermediateTables = $relation->getIntermediateTables(); 239 | 240 | foreach ($intermediateTables as $accessor => $table) { 241 | $tables[$i][] = [ 242 | 'accessor' => $accessor, 243 | 'columns' => $table['columns'], 244 | 'table' => $table['table'], 245 | ]; 246 | } 247 | } 248 | } 249 | 250 | if (count($tables) === count($relations)) { 251 | $hashes = array_map( 252 | fn (array $table) => serialize($table), 253 | $tables 254 | ); 255 | 256 | $uniqueHashes = array_unique($hashes); 257 | 258 | if (count($uniqueHashes) === 1) { 259 | return $tables[0]; 260 | } 261 | } 262 | 263 | return []; 264 | } 265 | 266 | /** 267 | * Get the foreign key for the merged relationship. 268 | * 269 | * @param \Illuminate\Database\Eloquent\Relations\Relation<*, *, *> $relation 270 | * @return string 271 | */ 272 | protected function getMergedForeignKey(Relation $relation) 273 | { 274 | if ($relation instanceof BelongsTo) { 275 | return $relation->getQualifiedParentKeyName(); 276 | } 277 | 278 | if ($this->isHasManyDeepRelationWithLeadingBelongsTo($relation)) { 279 | /** @var \Staudenmeir\EloquentHasManyDeep\HasManyDeep<*, *>|\Staudenmeir\EloquentHasManyDeep\HasOneDeep<*, *> $relation */ 280 | return $relation->getFarParent()->getQualifiedKeyName(); 281 | } 282 | 283 | return $this->getOriginalForeignKey($relation); 284 | } 285 | 286 | /** 287 | * Add relation-specific constraints to the query. 288 | * 289 | * @param \Illuminate\Database\Eloquent\Relations\Relation<*, *, *> $relation 290 | * @return void 291 | */ 292 | protected function addRelationQueryConstraints(Relation $relation) 293 | { 294 | if ($relation instanceof BelongsTo) { 295 | $relation->getQuery()->distinct() 296 | ->join( 297 | $relation->getParent()->getTable(), 298 | $relation->getQualifiedForeignKeyName(), 299 | '=', 300 | $relation->getQualifiedOwnerKeyName() 301 | ); 302 | } 303 | 304 | if ($this->isHasManyDeepRelationWithLeadingBelongsTo($relation)) { 305 | /** @var \Staudenmeir\EloquentHasManyDeep\HasManyDeep<*, *> $relation */ 306 | $relation->getQuery() 307 | ->join( 308 | $relation->getFarParent()->getTable(), 309 | $relation->getQualifiedLocalKeyName(), 310 | '=', 311 | $relation->getQualifiedFirstKeyName() 312 | ); 313 | } 314 | } 315 | 316 | /** 317 | * Determine if the relationship is a HasManyDeep relationship that starts with a BelongsTo relationship. 318 | * 319 | * @param \Illuminate\Database\Eloquent\Relations\Relation<*, *, *> $relation 320 | * @return bool 321 | */ 322 | protected function isHasManyDeepRelationWithLeadingBelongsTo(Relation $relation): bool 323 | { 324 | return ($relation instanceof HasManyDeep || $relation instanceof HasOneDeep) 325 | && $relation->getFirstKeyName() === $relation->getParent()->getKeyName(); 326 | } 327 | } 328 | -------------------------------------------------------------------------------- /src/Schema/Builders/MariaDbBuilder.php: -------------------------------------------------------------------------------- 1 |