├── LICENSE ├── README.md ├── composer.json └── src ├── Compoships.php ├── Database ├── Eloquent │ ├── Concerns │ │ └── HasRelationships.php │ ├── Factories │ │ ├── ComposhipsFactory.php │ │ └── Relationship.php │ ├── Model.php │ └── Relations │ │ ├── BelongsTo.php │ │ ├── HasMany.php │ │ ├── HasOne.php │ │ └── HasOneOrMany.php └── Query │ └── Builder.php └── Exceptions └── InvalidUsageException.php /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Awobaz Technologies Inc. 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Compoships 2 | ========== 3 | 4 | **Compoships** offers the ability to specify relationships based on two (or more) columns in Laravel's Eloquent ORM. The need to match multiple columns in the definition of an Eloquent relationship often arises when working with third party or pre existing schema/database. 5 | 6 | ## The problem 7 | 8 | Eloquent doesn't support composite keys. As a consequence, there is no way to define a relationship from one model to another by matching more than one column. Trying to use `where clauses` (like in the example below) won't work when eager loading the relationship because at the time the relationship is processed **$this->team_id** is null. 9 | 10 | ```php 11 | namespace App; 12 | 13 | use Illuminate\Database\Eloquent\Model; 14 | 15 | class User extends Model 16 | { 17 | public function tasks() 18 | { 19 | //WON'T WORK WITH EAGER LOADING!!! 20 | return $this->hasMany(Task::class)->where('team_id', $this->team_id); 21 | } 22 | } 23 | ``` 24 | 25 | #### Related discussions: 26 | 27 | * [Relationship on multiple keys](https://laracasts.com/discuss/channels/eloquent/relationship-on-multiple-keys) 28 | * [Querying relations with extra conditions not working as expected](https://github.com/laravel/framework/issues/1272) 29 | * [Querying relations with extra conditions in Eager Loading not working](https://github.com/laravel/framework/issues/19488) 30 | * [BelongsTo relationship with 2 foreign keys](https://laravel.io/forum/08-02-2014-belongsto-relationship-with-2-foreign-keys) 31 | * [Laravel Eloquent: multiple foreign keys for relationship](https://stackoverflow.com/questions/48077890/laravel-eloquent-multiple-foreign-keys-for-relationship/49834070#49834070) 32 | * [Laravel hasMany association with multiple columns](https://stackoverflow.com/questions/32471084/laravel-hasmany-association-with-multiple-columns) 33 | 34 | ## Installation 35 | 36 | The recommended way to install **Compoships** is through [Composer](http://getcomposer.org/) 37 | 38 | ```bash 39 | $ composer require awobaz/compoships 40 | ``` 41 | ## Usage 42 | 43 | ### Using the `Awobaz\Compoships\Database\Eloquent\Model` class 44 | 45 | Simply make your model class derive from the `Awobaz\Compoships\Database\Eloquent\Model` base class. The `Awobaz\Compoships\Database\Eloquent\Model` extends the `Eloquent` base class without changing its core functionality. 46 | 47 | ### Using the `Awobaz\Compoships\Compoships` trait 48 | 49 | If for some reason you can't derive your models from `Awobaz\Compoships\Database\Eloquent\Model`, you may take advantage of the `Awobaz\Compoships\Compoships` trait. Simply use the trait in your models. 50 | 51 | **Note:** To define a multi-columns relationship from a model *A* to another model *B*, **both models must either extend `Awobaz\Compoships\Database\Eloquent\Model` or use the `Awobaz\Compoships\Compoships` trait** 52 | 53 | ### Syntax 54 | 55 | ... and now we can define a relationship from a model *A* to another model *B* by matching two or more columns (by passing an array of columns instead of a string). 56 | 57 | ```php 58 | namespace App; 59 | 60 | use Illuminate\Database\Eloquent\Model; 61 | 62 | class A extends Model 63 | { 64 | use \Awobaz\Compoships\Compoships; 65 | 66 | public function b() 67 | { 68 | return $this->hasMany('B', ['foreignKey1', 'foreignKey2'], ['localKey1', 'localKey2']); 69 | } 70 | } 71 | ``` 72 | 73 | We can use the same syntax to define the inverse of the relationship: 74 | 75 | ```php 76 | namespace App; 77 | 78 | use Illuminate\Database\Eloquent\Model; 79 | 80 | class B extends Model 81 | { 82 | use \Awobaz\Compoships\Compoships; 83 | 84 | public function a() 85 | { 86 | return $this->belongsTo('A', ['foreignKey1', 'foreignKey2'], ['ownerKey1', 'ownerKey2']); 87 | } 88 | } 89 | ``` 90 | 91 | ### Factories 92 | 93 | Chances are that you may need factories for your Compoships models. If so, you will probably need to use 94 | Factory methods to create relationship models. For example, by using the ->has() method. Just use the 95 | ``Awobaz\Compoships\Database\Eloquent\Factories\ComposhipsFactory`` trait in your factory classes to be able 96 | to use relationships correctly. 97 | 98 | ### Example 99 | 100 | As an example, let's pretend we have a task list with categories, managed by several teams of users where: 101 | * a task belongs to a category 102 | * a task is assigned to a team 103 | * a team has many users 104 | * a user belongs to one team 105 | * a user is responsible for one category of tasks 106 | 107 | The user responsible for a particular task is the user _currently_ in charge for the category inside the team. 108 | 109 | ```php 110 | namespace App; 111 | 112 | use Illuminate\Database\Eloquent\Model; 113 | 114 | class User extends Model 115 | { 116 | use \Awobaz\Compoships\Compoships; 117 | 118 | public function tasks() 119 | { 120 | return $this->hasMany(Task::class, ['team_id', 'category_id'], ['team_id', 'category_id']); 121 | } 122 | } 123 | ``` 124 | 125 | Again, same syntax to define the inverse of the relationship: 126 | 127 | ```php 128 | namespace App; 129 | 130 | use Illuminate\Database\Eloquent\Model; 131 | 132 | class Task extends Model 133 | { 134 | use \Awobaz\Compoships\Compoships; 135 | 136 | public function user() 137 | { 138 | return $this->belongsTo(User::class, ['team_id', 'category_id'], ['team_id', 'category_id']); 139 | } 140 | } 141 | ``` 142 | ## Supported relationships 143 | 144 | **Compoships** only supports the following Laravel's Eloquent relationships: 145 | 146 | * hasOne 147 | * HasMany 148 | * belongsTo 149 | 150 | Also please note that while **nullable columns are supported by Compoships**, relationships with only null values are not currently possible. 151 | 152 | ## Support for nullable columns in 2.x 153 | 154 | Version 2.x brings support for nullable columns. The results may now be different than on version 1.x when a column is null on a relationship, so we bumped the version to 2.x, as this might be a breaking change. 155 | 156 | ## Disclaimer 157 | 158 | **Compoships** doesn't bring support for composite keys in Laravel's Eloquent. This package only offers the ability to specify relationships based on more than one column. In a Laravel project, it's recommended for all models' tables to have a single primary key. But there are situations where you'll need to match many columns in the definition of a relationship even when your models' tables have a single primary key. 159 | 160 | ## Contributing 161 | 162 | Please read [CONTRIBUTING.md](https://github.com/topclaudy/compoships/blob/master/CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull requests. 163 | 164 | 165 | [![](https://sourcerer.io/fame/topclaudy/topclaudy/compoships/images/0)](https://sourcerer.io/fame/topclaudy/topclaudy/compoships/links/0) 166 | [![](https://sourcerer.io/fame/topclaudy/topclaudy/compoships/images/1)](https://sourcerer.io/fame/topclaudy/topclaudy/compoships/links/1) 167 | [![](https://sourcerer.io/fame/topclaudy/topclaudy/compoships/images/2)](https://sourcerer.io/fame/topclaudy/topclaudy/compoships/links/2) 168 | [![](https://sourcerer.io/fame/topclaudy/topclaudy/compoships/images/3)](https://sourcerer.io/fame/topclaudy/topclaudy/compoships/links/3) 169 | [![](https://sourcerer.io/fame/topclaudy/topclaudy/compoships/images/4)](https://sourcerer.io/fame/topclaudy/topclaudy/compoships/links/4) 170 | [![](https://sourcerer.io/fame/topclaudy/topclaudy/compoships/images/5)](https://sourcerer.io/fame/topclaudy/topclaudy/compoships/links/5) 171 | [![](https://sourcerer.io/fame/topclaudy/topclaudy/compoships/images/6)](https://sourcerer.io/fame/topclaudy/topclaudy/compoships/links/6) 172 | 173 | 174 | ## Versioning 175 | 176 | We use [SemVer](http://semver.org/) for versioning. For the versions available, see the [tags on this repository](https://github.com/topclaudy/compoships/tags). 177 | 178 | ## Unit Tests 179 | 180 | To run unit tests you have to use PHPUnit 181 | 182 | Install compoships repository 183 | ```bash 184 | git clone https://github.com/topclaudy/compoships.git 185 | cd compoships 186 | composer install 187 | ``` 188 | Run PHPUnit 189 | ```bash 190 | ./vendor/bin/phpunit 191 | ``` 192 | 193 | ## Authors 194 | 195 | * [Claudin J. Daniel](https://github.com/topclaudy) - *Initial work* 196 | 197 | ## Support This Project 198 | 199 | Buy Me a Coffee via Paypal 200 | 201 | ## License 202 | 203 | **Compoships** is licensed under the [MIT License](http://opensource.org/licenses/MIT). 204 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "awobaz/compoships", 3 | "description": "Laravel relationships with support for composite/multiple keys", 4 | "license": "MIT", 5 | "keywords": [ 6 | "laravel", 7 | "laravel composite keys", 8 | "laravel relationships" 9 | ], 10 | "authors": [ 11 | { 12 | "name": "Claudin J. Daniel", 13 | "email": "cdaniel@awobaz.com" 14 | } 15 | ], 16 | "require": { 17 | "illuminate/database": ">=5.6 <13.0" 18 | }, 19 | "require-dev": { 20 | "ext-sqlite3": "*", 21 | "fakerphp/faker": "^1.18", 22 | "phpunit/phpunit": "^6.0|^8.0|^9.0|^10.0|^11.0" 23 | }, 24 | "autoload": { 25 | "psr-4": { 26 | "Awobaz\\Compoships\\": "src" 27 | } 28 | }, 29 | "autoload-dev": { 30 | "psr-4": { 31 | "Awobaz\\Compoships\\Tests\\": "tests" 32 | } 33 | }, 34 | "minimum-stability": "dev", 35 | "prefer-stable": true, 36 | "suggest": { 37 | "awobaz/eloquent-mutators": "Reusable mutators (getters/setters) for Laravel 5's Eloquent", 38 | "awobaz/eloquent-auto-append": "Automatically append accessors to model serialization", 39 | "awobaz/blade-active": "Blade directives for the Laravel 'Active' package", 40 | "awobaz/syntactic": "Syntactic sugar for named and indexed parameters call." 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Compoships.php: -------------------------------------------------------------------------------- 1 | getTable().'.'.$c; 33 | }, $column); 34 | } 35 | 36 | return parent::qualifyColumn($column); 37 | } 38 | 39 | /** 40 | * Configure Eloquent to use Compoships Query Builder. 41 | * 42 | * @return \Awobaz\Compoships\Database\Query\Builder|static 43 | */ 44 | protected function newBaseQueryBuilder() 45 | { 46 | $connection = $this->getConnection(); 47 | 48 | return new QueryBuilder($connection, $connection->getQueryGrammar(), $connection->getPostProcessor()); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Database/Eloquent/Concerns/HasRelationships.php: -------------------------------------------------------------------------------- 1 | getKeyName(); 26 | 27 | if (is_array($keyName)) { //Check for multi-columns relationship 28 | $keys = []; 29 | 30 | foreach ($keyName as $key) { 31 | $keys[] = $this->getTable().$key; 32 | } 33 | 34 | return $keys; 35 | } 36 | 37 | return $this->getTable().'.'.$keyName; 38 | } 39 | 40 | /** 41 | * Define a one-to-one relationship. 42 | * 43 | * @template TRelatedModel of \Illuminate\Database\Eloquent\Model 44 | * 45 | * @param class-string $related 46 | * @param string|array|null $foreignKey 47 | * @param string|array|null $localKey 48 | * 49 | * @return \Awobaz\Compoships\Database\Eloquent\Relations\HasOne 50 | */ 51 | public function hasOne($related, $foreignKey = null, $localKey = null) 52 | { 53 | if (is_array($foreignKey)) { //Check for multi-columns relationship 54 | $this->validateRelatedModel($related); 55 | } 56 | 57 | $instance = $this->newRelatedInstance($related); 58 | 59 | $foreignKey = $foreignKey ?: $this->getForeignKey(); 60 | 61 | $foreignKeys = null; 62 | 63 | if (is_array($foreignKey)) { //Check for multi-columns relationship 64 | foreach ($foreignKey as $key) { 65 | $foreignKeys[] = $this->sanitizeKey($instance, $key); 66 | } 67 | } else { 68 | $foreignKey = $this->sanitizeKey($instance, $foreignKey); 69 | } 70 | 71 | $localKey = $localKey ?: $this->getKeyName(); 72 | 73 | return $this->newHasOne($instance->newQuery(), $this, $foreignKeys ?: $foreignKey, $localKey); 74 | } 75 | 76 | /** 77 | * Instantiate a new HasOne relationship. 78 | * 79 | * @template TRelatedModel of \Illuminate\Database\Eloquent\Model 80 | * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model 81 | * 82 | * @param \Illuminate\Database\Eloquent\Builder $query 83 | * @param TDeclaringModel $parent 84 | * @param string|array $foreignKey 85 | * @param string|array $localKey 86 | * 87 | * @return \Awobaz\Compoships\Database\Eloquent\Relations\HasOne 88 | */ 89 | protected function newHasOne(Builder $query, Model $parent, $foreignKey, $localKey) 90 | { 91 | return new HasOne($query, $parent, $foreignKey, $localKey); 92 | } 93 | 94 | /** 95 | * Validate the related model for Compoships compatibility. 96 | * 97 | * @param $related 98 | * 99 | * @throws InvalidUsageException 100 | */ 101 | private function validateRelatedModel($related) 102 | { 103 | $traitClass = Compoships::class; 104 | if (!array_key_exists($traitClass, class_uses_recursive($related))) { 105 | throw new InvalidUsageException("The related model '{$related}' must use the '{$traitClass}' trait"); 106 | } 107 | } 108 | 109 | /** 110 | * Define a one-to-many relationship. 111 | * 112 | * @template TRelatedModel of \Illuminate\Database\Eloquent\Model 113 | * 114 | * @param class-string $related 115 | * @param string|array|null $foreignKey 116 | * @param string|array|null $localKey 117 | * 118 | * @return \Awobaz\Compoships\Database\Eloquent\Relations\HasMany 119 | */ 120 | public function hasMany($related, $foreignKey = null, $localKey = null) 121 | { 122 | if (is_array($foreignKey)) { //Check for multi-columns relationship 123 | $this->validateRelatedModel($related); 124 | } 125 | 126 | $instance = $this->newRelatedInstance($related); 127 | 128 | $foreignKey = $foreignKey ?: $this->getForeignKey(); 129 | 130 | $foreignKeys = null; 131 | 132 | if (is_array($foreignKey)) { //Check for multi-columns relationship 133 | foreach ($foreignKey as $key) { 134 | $foreignKeys[] = $this->sanitizeKey($instance, $key); 135 | } 136 | } else { 137 | $foreignKey = $this->sanitizeKey($instance, $foreignKey); 138 | } 139 | 140 | $localKey = $localKey ?: $this->getKeyName(); 141 | 142 | return $this->newHasMany($instance->newQuery(), $this, $foreignKeys ?: $foreignKey, $localKey); 143 | } 144 | 145 | /** 146 | * Instantiate a new HasMany relationship. 147 | * 148 | * @template TRelatedModel of \Illuminate\Database\Eloquent\Model 149 | * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model 150 | * 151 | * @param \Illuminate\Database\Eloquent\Builder $query 152 | * @param TDeclaringModel $parent 153 | * @param string|array $foreignKey 154 | * @param string|array $localKey 155 | * 156 | * @return \Awobaz\Compoships\Database\Eloquent\Relations\HasMany 157 | */ 158 | protected function newHasMany(Builder $query, Model $parent, $foreignKey, $localKey) 159 | { 160 | return new HasMany($query, $parent, $foreignKey, $localKey); 161 | } 162 | 163 | /** 164 | * Define an inverse one-to-one or many relationship. 165 | * 166 | * @template TRelatedModel of \Illuminate\Database\Eloquent\Model 167 | * 168 | * @param class-string $related 169 | * @param string|array|null $foreignKey 170 | * @param string|array|null $ownerKey 171 | * @param string $relation 172 | * 173 | * @return \Awobaz\Compoships\Database\Eloquent\Relations\BelongsTo 174 | */ 175 | public function belongsTo($related, $foreignKey = null, $ownerKey = null, $relation = null) 176 | { 177 | if (is_array($foreignKey)) { //Check for multi-columns relationship 178 | $this->validateRelatedModel($related); 179 | } 180 | 181 | // If no relation name was given, we will use this debug backtrace to extract 182 | // the calling method's name and use that as the relationship name as most 183 | // of the time this will be what we desire to use for the relationships. 184 | if (is_null($relation)) { 185 | $relation = $this->guessBelongsToRelation(); 186 | } 187 | 188 | $instance = $this->newRelatedInstance($related); 189 | 190 | // If no foreign key was supplied, we can use a backtrace to guess the proper 191 | // foreign key name by using the name of the relationship function, which 192 | // when combined with an "_id" should conventionally match the columns. 193 | if (is_null($foreignKey)) { 194 | $foreignKey = Str::snake($relation).'_'.$instance->getKeyName(); 195 | } 196 | 197 | // Once we have the foreign key names, we'll just create a new Eloquent query 198 | // for the related models and returns the relationship instance which will 199 | // actually be responsible for retrieving and hydrating every relations. 200 | $ownerKey = $ownerKey ?: $instance->getKeyName(); 201 | 202 | return $this->newBelongsTo($instance->newQuery(), $this, $foreignKey, $ownerKey, $relation); 203 | } 204 | 205 | /** 206 | * Instantiate a new BelongsTo relationship. 207 | * 208 | * @template TRelatedModel of \Illuminate\Database\Eloquent\Model 209 | * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model 210 | * 211 | * @param \Illuminate\Database\Eloquent\Builder $query 212 | * @param TDeclaringModel $child 213 | * @param string|array $foreignKey 214 | * @param string|array $ownerKey 215 | * @param string $relation 216 | * 217 | * @return \Awobaz\Compoships\Database\Eloquent\Relations\BelongsTo 218 | */ 219 | protected function newBelongsTo(Builder $query, Model $child, $foreignKey, $ownerKey, $relation) 220 | { 221 | return new BelongsTo($query, $child, $foreignKey, $ownerKey, $relation); 222 | } 223 | 224 | /** 225 | * Honor DB::raw instances. 226 | * 227 | * @param string $instance 228 | * @param string $foreignKey 229 | * 230 | * @return string|Expression 231 | */ 232 | protected function sanitizeKey($instance, $foreignKey) 233 | { 234 | $grammar = $this->getConnection() 235 | ->getQueryGrammar(); 236 | 237 | return $grammar->isExpression($foreignKey) 238 | ? $foreignKey 239 | : $instance->getTable().'.'.$foreignKey; 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /src/Database/Eloquent/Factories/ComposhipsFactory.php: -------------------------------------------------------------------------------- 1 | newInstance([ 12 | 'has' => $this->has->concat([new Relationship( 13 | $factory, 14 | $relationship ?? $this->guessRelationship($factory->modelName()) 15 | )]), 16 | ]); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Database/Eloquent/Factories/Relationship.php: -------------------------------------------------------------------------------- 1 | {$this->relationship}(); 16 | 17 | if ($relationship instanceof MorphOneOrMany) { 18 | $this->factory->state([ 19 | $relationship->getMorphType() => $relationship->getMorphClass(), 20 | $relationship->getForeignKeyName() => $relationship->getParentKey(), 21 | ])->create([], $parent); 22 | } elseif ($relationship instanceof HasOneOrMany) { // This relationship is supported by Compoships. Check for multi-columns relationship. 23 | $this->factory->state( 24 | is_array($relationship->getForeignKeyName()) ? 25 | array_combine($relationship->getForeignKeyName(), $relationship->getParentKey()) : 26 | [$relationship->getForeignKeyName() => $relationship->getParentKey()] 27 | )->create([], $parent); 28 | } elseif ($relationship instanceof BelongsToMany) { 29 | $relationship->attach($this->factory->create([], $parent)); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Database/Eloquent/Model.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class BelongsTo extends BaseBelongsTo 17 | { 18 | /** 19 | * Get the results of the relationship. 20 | * 21 | * @return mixed 22 | */ 23 | public function getResults() 24 | { 25 | if (!is_array($this->foreignKey)) { 26 | if (is_null($this->child->{$this->foreignKey})) { 27 | return $this->getDefaultFor($this->parent); 28 | } 29 | } 30 | 31 | return $this->query->first() ?: $this->getDefaultFor($this->parent); 32 | } 33 | 34 | /** 35 | * Associate the model instance to the given parent. 36 | * 37 | * @param \Illuminate\Database\Eloquent\Model|int|string|null $model 38 | * 39 | * @return \Illuminate\Database\Eloquent\Model 40 | */ 41 | public function associate($model) 42 | { 43 | if (!is_array($this->ownerKey)) { 44 | return parent::associate($model); 45 | } 46 | 47 | $ownerKey = $model instanceof Model ? $model->getAttribute($this->ownerKey) : $model; 48 | for ($i = 0; $i < count($this->foreignKey); $i++) { 49 | $foreignKey = $this->foreignKey[$i]; 50 | $value = $ownerKey[$i]; 51 | $this->child->setAttribute($foreignKey, $value); 52 | } 53 | // BC break in 5.8 : https://github.com/illuminate/database/commit/87b9833019f48b88d98a6afc46f38ce37f08237d 54 | $relationName = property_exists($this, 'relationName') ? $this->relationName : $this->relation; 55 | if ($model instanceof Model) { 56 | $this->child->setRelation($relationName, $model); 57 | // proper unset // https://github.com/illuminate/database/commit/44411c7288fc7b7d4e5680cfcdaa46d348b5c981 58 | } elseif ($this->child->isDirty($this->foreignKey)) { 59 | $this->child->unsetRelation($relationName); 60 | } 61 | 62 | return $this->child; 63 | } 64 | 65 | /** 66 | * Set the base constraints on the relation query. 67 | * 68 | * @return void 69 | */ 70 | public function addConstraints() 71 | { 72 | if (static::$constraints) { 73 | // For belongs to relationships, which are essentially the inverse of has one 74 | // or has many relationships, we need to actually query on the primary key 75 | // of the related models matching on the foreign key that's on a parent. 76 | $table = $this->related->getTable(); 77 | 78 | if (is_array($this->ownerKey)) { //Check for multi-columns relationship 79 | $childAttributes = $this->child->getAttributes(); 80 | 81 | $allOwnerKeyValuesAreNull = array_unique(array_values( 82 | array_intersect_key($childAttributes, array_flip($this->ownerKey)) 83 | )) === [null]; 84 | 85 | foreach ($this->ownerKey as $index => $key) { 86 | $fullKey = $table.'.'.$key; 87 | 88 | if (array_key_exists($this->foreignKey[$index], $childAttributes)) { 89 | $this->query->where($fullKey, '=', $this->child->{$this->foreignKey[$index]}); 90 | } 91 | 92 | if ($allOwnerKeyValuesAreNull) { 93 | $this->query->whereNotNull($fullKey); 94 | } 95 | } 96 | } else { 97 | parent::addConstraints(); 98 | } 99 | } 100 | } 101 | 102 | /** 103 | * Set the constraints for an eager load of the relation. 104 | * 105 | * @param array $models 106 | * 107 | * @return void 108 | */ 109 | public function addEagerConstraints(array $models) 110 | { 111 | if (is_array($this->ownerKey)) { //Check for multi-columns relationship 112 | $keys = []; 113 | 114 | foreach ($this->ownerKey as $key) { 115 | $keys[] = $this->related->getTable().'.'.$key; 116 | } 117 | 118 | // method \Awobaz\Compoships\Database\Eloquent\Relations\HasOneOrMany::whereInMethod 119 | // 5.6 - does not exist 120 | // 5.7 - added in 5.7.17 / https://github.com/illuminate/database/commit/9af300d1c50c9ec526823c1e6548daa3949bf9a9 121 | $this->query->whereIn($keys, $this->getEagerModelKeys($models)); 122 | } else { 123 | parent::addEagerConstraints($models); 124 | } 125 | } 126 | 127 | /** 128 | * Gather the keys from an array of related models. 129 | * 130 | * @param array $models 131 | * 132 | * @return array 133 | */ 134 | protected function getEagerModelKeys(array $models) 135 | { 136 | if (is_array($this->foreignKey)) { 137 | return $this->getEagerModelKeysForArray($models); 138 | } 139 | 140 | return parent::getEagerModelKeys($models); 141 | } 142 | 143 | /** 144 | * Gather the keys from an array of related models that 145 | * are using a composite related key. 146 | * 147 | * @param array $models 148 | * 149 | * @return array 150 | */ 151 | protected function getEagerModelKeysForArray(array $models) 152 | { 153 | $keys = []; 154 | 155 | // First we need to gather all of the keys from the parent models so we know what 156 | // to query for via the eager loading query. We will add them to an array then 157 | // execute a "where in" statement to gather up all of those related records. 158 | foreach ($models as $model) { 159 | $keys[] = array_map(function ($k) use ($model) { 160 | return $model->{$k}; 161 | }, $this->foreignKey); 162 | } 163 | 164 | sort($keys); 165 | 166 | return array_map('unserialize', array_unique(array_map('serialize', $keys))); 167 | } 168 | 169 | /** 170 | * Get the fully qualified foreign key of the relationship. 171 | * 172 | * @return string 173 | */ 174 | public function getQualifiedForeignKey() 175 | { 176 | if (is_array($this->foreignKey)) { //Check for multi-columns relationship 177 | return array_map(function ($k) { 178 | return $this->child->getTable().'.'.$k; 179 | }, $this->foreignKey); 180 | } else { 181 | return $this->child->getTable().'.'.$this->foreignKey; 182 | } 183 | } 184 | 185 | /** 186 | * Add the constraints for a relationship query. 187 | * 188 | * @param \Illuminate\Database\Eloquent\Builder $query 189 | * @param \Illuminate\Database\Eloquent\Builder $parentQuery 190 | * @param array|mixed $columns 191 | * 192 | * @return \Illuminate\Database\Eloquent\Builder 193 | */ 194 | public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) 195 | { 196 | if ($parentQuery->getQuery()->from == $query->getQuery()->from) { 197 | return $this->getRelationExistenceQueryForSelfRelation($query, $parentQuery, $columns); 198 | } 199 | 200 | $modelTable = $query->getModel() 201 | ->getTable(); 202 | 203 | return $query->select($columns) 204 | ->whereColumn( 205 | $this->getQualifiedForeignKey(), 206 | '=', 207 | is_array($this->ownerKey) ? //Check for multi-columns relationship 208 | array_map(function ($k) use ($modelTable) { 209 | return $modelTable.'.'.$k; 210 | }, $this->ownerKey) : $modelTable.'.'.$this->ownerKey 211 | ); 212 | } 213 | 214 | /** 215 | * Match the eagerly loaded results to their parents. 216 | * 217 | * @param array $models 218 | * @param \Illuminate\Database\Eloquent\Collection $results 219 | * @param string $relation 220 | * 221 | * @return array 222 | */ 223 | public function match(array $models, Collection $results, $relation) 224 | { 225 | $foreign = $this->foreignKey; 226 | 227 | $owner = $this->ownerKey; 228 | 229 | // First we will get to build a dictionary of the child models by their primary 230 | // key of the relationship, then we can easily match the children back onto 231 | // the parents using that dictionary and the primary key of the children. 232 | $dictionary = []; 233 | 234 | foreach ($results as $result) { 235 | if (is_array($owner)) { //Check for multi-columns relationship 236 | $dictKeyValues = array_map(function ($k) use ($result) { 237 | return $result->{$k} instanceof \BackedEnum ? $result->{$k}->value : $result->{$k}; 238 | }, $owner); 239 | 240 | $dictionary[implode('-', $dictKeyValues)] = $result; 241 | } else { 242 | $dictionary[$result->getAttribute($owner)] = $result; 243 | } 244 | } 245 | 246 | // Once we have the dictionary constructed, we can loop through all the parents 247 | // and match back onto their children using these keys of the dictionary and 248 | // the primary key of the children to map them onto the correct instances. 249 | foreach ($models as $model) { 250 | if (is_array($foreign)) { //Check for multi-columns relationship 251 | $dictKeyValues = array_map(function ($k) use ($model) { 252 | return $model->{$k} instanceof \BackedEnum ? $model->{$k}->value : $model->{$k}; 253 | }, $foreign); 254 | 255 | $key = implode('-', $dictKeyValues); 256 | } else { 257 | $key = $model->{$foreign}; 258 | } 259 | 260 | if (isset($dictionary[$key])) { 261 | $model->setRelation($relation, $dictionary[$key]); 262 | } 263 | } 264 | 265 | return $models; 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /src/Database/Eloquent/Relations/HasMany.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class HasMany extends BaseHasMany 15 | { 16 | use HasOneOrMany; 17 | 18 | /** 19 | * Get the results of the relationship. 20 | * 21 | * @return mixed 22 | */ 23 | public function getResults() 24 | { 25 | if (!is_array($this->getParentKey())) { 26 | return !is_null($this->getParentKey()) ? $this->query->get() : $this->related->newCollection(); 27 | } 28 | 29 | return $this->query->get(); 30 | } 31 | 32 | /** 33 | * Initialize the relation on a set of models. 34 | * 35 | * @param array $models 36 | * @param string $relation 37 | * 38 | * @return array 39 | */ 40 | public function initRelation(array $models, $relation) 41 | { 42 | foreach ($models as $model) { 43 | $model->setRelation($relation, $this->related->newCollection()); 44 | } 45 | 46 | return $models; 47 | } 48 | 49 | /** 50 | * Match the eagerly loaded results to their parents. 51 | * 52 | * @param array $models 53 | * @param \Illuminate\Database\Eloquent\Collection $results 54 | * @param string $relation 55 | * 56 | * @return array 57 | */ 58 | public function match(array $models, Collection $results, $relation) 59 | { 60 | return $this->matchMany($models, $results, $relation); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Database/Eloquent/Relations/HasOne.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class HasOne extends BaseHasOne 16 | { 17 | use HasOneOrMany; 18 | 19 | /** 20 | * Get the results of the relationship. 21 | * 22 | * @return mixed 23 | */ 24 | public function getResults() 25 | { 26 | if (!is_array($this->getParentKey())) { 27 | if (is_null($this->getParentKey())) { 28 | return $this->getDefaultFor($this->parent); 29 | } 30 | } 31 | 32 | return $this->query->first() ?: $this->getDefaultFor($this->parent); 33 | } 34 | 35 | /** 36 | * Get the default value for this relation. 37 | * 38 | * @param \Illuminate\Database\Eloquent\Model $model 39 | * 40 | * @return \Illuminate\Database\Eloquent\Model|null 41 | */ 42 | protected function getDefaultFor(Model $model) 43 | { 44 | if (!$this->withDefault) { 45 | return; 46 | } 47 | 48 | $instance = $this->related->newInstance(); 49 | 50 | $foreignKey = $this->getForeignKeyName(); 51 | 52 | if (is_array($foreignKey)) { //Check for multi-columns relationship 53 | foreach ($foreignKey as $index => $key) { 54 | $instance->setAttribute($key, $model->getAttribute($this->localKey[$index])); 55 | } 56 | } else { 57 | $instance->setAttribute($foreignKey, $model->getAttribute($this->localKey)); 58 | } 59 | 60 | if (is_callable($this->withDefault)) { 61 | return call_user_func($this->withDefault, $instance) ?: $instance; 62 | } 63 | 64 | if (is_array($this->withDefault)) { 65 | $instance->forceFill($this->withDefault); 66 | } 67 | 68 | return $instance; 69 | } 70 | 71 | /** 72 | * Initialize the relation on a set of models. 73 | * 74 | * @param array $models 75 | * @param string $relation 76 | * 77 | * @return array 78 | */ 79 | public function initRelation(array $models, $relation) 80 | { 81 | foreach ($models as $model) { 82 | $model->setRelation($relation, $this->getDefaultFor($model)); 83 | } 84 | 85 | return $models; 86 | } 87 | 88 | /** 89 | * Match the eagerly loaded results to their parents. 90 | * 91 | * @param array $models 92 | * @param \Illuminate\Database\Eloquent\Collection $results 93 | * @param string $relation 94 | * 95 | * @return array 96 | */ 97 | public function match(array $models, Collection $results, $relation) 98 | { 99 | return $this->matchOne($models, $results, $relation); 100 | } 101 | 102 | /** 103 | * Make a new related instance for the given model. 104 | * 105 | * @param \Illuminate\Database\Eloquent\Model $parent 106 | * 107 | * @return \Illuminate\Database\Eloquent\Model 108 | */ 109 | public function newRelatedInstanceFor(Model $parent) 110 | { 111 | $newInstance = $this->related->newInstance(); 112 | 113 | if (is_array($this->localKey)) { //Check for multi-columns relationship 114 | $foreignKey = $this->getForeignKeyName(); 115 | 116 | foreach ($this->localKey as $index => $key) { 117 | $newInstance->setAttribute($foreignKey[$index], $parent->{$key}); 118 | } 119 | } else { 120 | return $newInstance->setAttribute($this->getForeignKeyName(), $parent->{$this->localKey}); 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Database/Eloquent/Relations/HasOneOrMany.php: -------------------------------------------------------------------------------- 1 | getForeignKeyName(); 21 | $parentKeyValue = $this->getParentKey(); 22 | 23 | $parentKeyValue = is_array($parentKeyValue) 24 | ? array_map(function ($v) { 25 | return $v instanceof \BackedEnum ? $v->value : $v; 26 | }, $parentKeyValue) 27 | : $parentKeyValue; 28 | 29 | //If the foreign key is an array (multi-column relationship), we adjust the query. 30 | if (is_array($this->foreignKey)) { 31 | $allParentKeyValuesAreNull = array_unique($parentKeyValue) === [null]; 32 | 33 | foreach ($this->foreignKey as $index => $key) { 34 | $tmp = explode('.', $key); 35 | $key = end($tmp); 36 | $fullKey = $this->getRelated() 37 | ->getTable().'.'.$key; 38 | $this->query->where($fullKey, '=', $parentKeyValue[$index]); 39 | 40 | if ($allParentKeyValuesAreNull) { 41 | $this->query->whereNotNull($fullKey); 42 | } 43 | } 44 | } else { 45 | parent::addConstraints(); 46 | } 47 | } 48 | } 49 | 50 | /** 51 | * Set the constraints for an eager load of the relation. 52 | * 53 | * @param array $models 54 | * 55 | * @return void 56 | * 57 | * >=7.x - no method Illuminate\Database\Eloquent\Relations\Relation::getRelationQuery 58 | * 10.x - no support array keys Illuminate\Database\Eloquent\Relations\Relation::whereInEager 59 | */ 60 | public function addEagerConstraints(array $models) 61 | { 62 | if (is_array($this->localKey)) { //Check for multi-columns relationship 63 | $whereIn = $this->whereInMethod($this->parent, $this->localKey); 64 | $modelKeys = $this->getKeys($models, $this->localKey); 65 | 66 | ((method_exists($this, 'getRelationQuery') ? $this->getRelationQuery() : null) ?? $this->query)->{$whereIn}($this->foreignKey, $modelKeys); 67 | 68 | if ($modelKeys === []) { 69 | $this->eagerKeysWereEmpty = true; 70 | } 71 | } else { 72 | parent::addEagerConstraints($models); 73 | } 74 | } 75 | 76 | /** 77 | * Get the name of the "where in" method for eager loading. 78 | * 79 | * @param \Illuminate\Database\Eloquent\Model $model 80 | * @param string|array $key 81 | * 82 | * @return string 83 | * 84 | * 5.6 - no method \Awobaz\Compoships\Database\Eloquent\Relations\HasOneOrMany::whereInMethod 85 | * added in this commit (5.7.17) https://github.com/illuminate/database/commit/9af300d1c50c9ec526823c1e6548daa3949bf9a9 86 | */ 87 | protected function whereInMethod(Model $model, $key) 88 | { 89 | if (!is_array($key)) { 90 | return parent::whereInMethod($model, $key); 91 | } 92 | 93 | $where = collect($key)->filter(function ($key) use ($model) { 94 | return $model->getKeyName() === last(explode('.', $key)) 95 | && in_array($model->getKeyType(), ['int', 'integer']); 96 | }); 97 | 98 | return $where->count() === count($key) ? 'whereIntegerInRaw' : 'whereIn'; 99 | } 100 | 101 | /** 102 | * Get the fully qualified parent key name. 103 | * 104 | * @return string 105 | */ 106 | public function getQualifiedParentKeyName() 107 | { 108 | if (is_array($this->localKey)) { //Check for multi-columns relationship 109 | return array_map(function ($k) { 110 | return $this->parent->getTable().'.'.$k; 111 | }, $this->localKey); 112 | } else { 113 | return $this->parent->getTable().'.'.$this->localKey; 114 | } 115 | } 116 | 117 | /** 118 | * Get the plain foreign key. 119 | * 120 | * @return string 121 | */ 122 | public function getForeignKeyName() 123 | { 124 | $key = $this->getQualifiedForeignKeyName(); 125 | 126 | if (is_array($key)) { //Check for multi-columns relationship 127 | return array_map(function ($k) { 128 | $segments = explode('.', $k); 129 | 130 | return $segments[count($segments) - 1]; 131 | }, $key); 132 | } else { 133 | $segments = explode('.', $key); 134 | 135 | return $segments[count($segments) - 1]; 136 | } 137 | } 138 | 139 | /** 140 | * Attach a model instance to the parent model. 141 | * 142 | * @param \Illuminate\Database\Eloquent\Model $model 143 | * 144 | * @return \Illuminate\Database\Eloquent\Model 145 | */ 146 | public function save(Model $model) 147 | { 148 | $foreignKey = $this->getForeignKeyName(); 149 | $parentKeyValue = $this->getParentKey(); 150 | 151 | if (is_array($foreignKey)) { //Check for multi-columns relationship 152 | foreach ($foreignKey as $index => $key) { 153 | $model->setAttribute($key, $parentKeyValue[$index]); 154 | } 155 | } else { 156 | $model->setAttribute($foreignKey, $parentKeyValue); 157 | } 158 | 159 | return $model->save() ? $model : false; 160 | } 161 | 162 | /** 163 | * Create a new instance of the related model. 164 | * 165 | * @param array $attributes 166 | * 167 | * @return \Illuminate\Database\Eloquent\Model 168 | */ 169 | public function create(array $attributes = []) 170 | { 171 | return tap($this->related->newInstance($attributes), function ($instance) { 172 | $foreignKey = $this->getForeignKeyName(); 173 | $parentKeyValue = $this->getParentKey(); 174 | 175 | if (is_array($foreignKey)) { //Check for multi-columns relationship 176 | foreach ($foreignKey as $index => $key) { 177 | $instance->setAttribute($key, $parentKeyValue[$index]); 178 | } 179 | } else { 180 | $instance->setAttribute($foreignKey, $parentKeyValue); 181 | } 182 | 183 | $instance->save(); 184 | }); 185 | } 186 | 187 | /** 188 | * Add the constraints for a relationship query on the same table. 189 | * 190 | * @param \Illuminate\Database\Eloquent\Builder $query 191 | * @param \Illuminate\Database\Eloquent\Builder $parentQuery 192 | * @param array|mixed $columns 193 | * 194 | * @return \Illuminate\Database\Eloquent\Builder 195 | */ 196 | public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder $parentQuery, $columns = ['*']) 197 | { 198 | $query->from($query->getModel() 199 | ->getTable().' as '.$hash = $this->getRelationCountHash()); 200 | 201 | $query->getModel() 202 | ->setTable($hash); 203 | 204 | return $query->select($columns) 205 | ->whereColumn( 206 | $this->getQualifiedParentKeyName(), 207 | '=', 208 | is_array($this->getForeignKeyName()) ? //Check for multi-columns relationship 209 | array_map(function ($k) use ($hash) { 210 | return $hash.'.'.$k; 211 | }, $this->getForeignKeyName()) : $hash.'.'.$this->getForeignKeyName() 212 | ); 213 | } 214 | 215 | /** 216 | * Match the eagerly loaded results to their many parents. 217 | * 218 | * @param array $models 219 | * @param \Illuminate\Database\Eloquent\Collection $results 220 | * @param string $relation 221 | * @param string $type 222 | * 223 | * @return array 224 | */ 225 | protected function matchOneOrMany(array $models, Collection $results, $relation, $type) 226 | { 227 | $dictionary = $this->buildDictionary($results); 228 | 229 | // Once we have the dictionary we can simply spin through the parent models to 230 | // link them up with their children using the keyed dictionary to make the 231 | // matching very convenient and easy work. Then we'll just return them. 232 | foreach ($models as $model) { 233 | $key = $model->getAttribute($this->localKey); 234 | //If the foreign key is an array, we know it's a multi-column relationship 235 | //And we join the values to construct the dictionary key 236 | $dictKey = is_array($key) ? implode('-', array_map(function ($v) { 237 | return $v instanceof \BackedEnum ? $v->value : $v; 238 | }, $key)) : $key; 239 | 240 | if (isset($dictionary[$dictKey])) { 241 | $model->setRelation($relation, $this->getRelationValue($dictionary, $dictKey, $type)); 242 | } 243 | } 244 | 245 | return $models; 246 | } 247 | 248 | /** 249 | * Build model dictionary keyed by the relation's foreign key. 250 | * 251 | * @param \Illuminate\Database\Eloquent\Collection $results 252 | * 253 | * @return array 254 | */ 255 | protected function buildDictionary(Collection $results) 256 | { 257 | $dictionary = []; 258 | 259 | $foreign = $this->getForeignKeyName(); 260 | 261 | // First we will create a dictionary of models keyed by the foreign key of the 262 | // relationship as this will allow us to quickly access all of the related 263 | // models without having to do nested looping which will be quite slow. 264 | foreach ($results as $result) { 265 | //If the foreign key is an array, we know it's a multi-column relationship... 266 | if (is_array($foreign)) { 267 | $dictKeyValues = array_map(function ($k) use ($result) { 268 | return $result->{$k} instanceof \BackedEnum ? $result->{$k}->value : $result->{$k}; 269 | }, $foreign); 270 | //... so we join the values to construct the dictionary key 271 | $dictionary[implode('-', $dictKeyValues)][] = $result; 272 | } else { 273 | $dictionary[$result->{$foreign}][] = $result; 274 | } 275 | } 276 | 277 | return $dictionary; 278 | } 279 | 280 | /** 281 | * Set the foreign ID for creating a related model. 282 | * 283 | * @param \Illuminate\Database\Eloquent\Model $model 284 | * 285 | * @return void 286 | */ 287 | protected function setForeignAttributesForCreate(Model $model) 288 | { 289 | $foreignKey = $this->getForeignKeyName(); 290 | $parentKeyValue = $this->getParentKey(); 291 | if (is_array($foreignKey)) { //Check for multi-columns relationship 292 | foreach ($foreignKey as $index => $key) { 293 | $model->setAttribute($key, $parentKeyValue[$index]); 294 | } 295 | } else { 296 | parent::setForeignAttributesForCreate($model); 297 | } 298 | } 299 | 300 | /** 301 | * Add join query constraints for one of many relationships. 302 | * 303 | * @param \Illuminate\Database\Eloquent\JoinClause $join 304 | * 305 | * @return void 306 | */ 307 | public function addOneOfManyJoinSubQueryConstraints(JoinClause $join) 308 | { 309 | if (is_array($this->foreignKey)) { 310 | foreach ($this->foreignKey as $key) { 311 | $join->on($this->qualifySubSelectColumn($key), '=', $this->qualifyRelatedColumn($key)); 312 | } 313 | } else { 314 | parent::addOneOfManyJoinSubQueryConstraints($join); 315 | } 316 | } 317 | } 318 | -------------------------------------------------------------------------------- /src/Database/Query/Builder.php: -------------------------------------------------------------------------------- 1 | where(function ($query) use ($column, $values) { 16 | foreach ($values as $value) { 17 | $query->orWhere(function ($query) use ($column, $value) { 18 | foreach ($column as $index => $aColumn) { 19 | $query->where($aColumn, $value[$index]); 20 | } 21 | }); 22 | } 23 | }); 24 | 25 | return $this; 26 | } 27 | 28 | return parent::whereIn($column, $values, $boolean, $not); 29 | } 30 | 31 | public function whereColumn($first, $operator = null, $second = null, $boolean = 'and') 32 | { 33 | // If the column and values are arrays, we will assume it is a multi-columns relationship 34 | // and we adjust the 'where' clauses accordingly 35 | if (is_array($first) && is_array($second)) { 36 | $type = 'Column'; 37 | 38 | foreach ($first as $index => $f) { 39 | $this->wheres[] = [ 40 | 'type' => $type, 41 | 'first' => $f, 42 | 'operator' => $operator, 43 | 'second' => $second[$index], 44 | 'boolean' => $boolean, 45 | 46 | ]; 47 | } 48 | 49 | return $this; 50 | } 51 | 52 | return parent::whereColumn($first, $operator, $second, $boolean); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidUsageException.php: -------------------------------------------------------------------------------- 1 |