├── src ├── JsonKey.php ├── Grammars │ ├── MySqlGrammar.php │ ├── MariaDbGrammar.php │ ├── JsonGrammar.php │ ├── SQLiteGrammar.php │ ├── SqlServerGrammar.php │ ├── PostgresGrammar.php │ └── Traits │ │ └── CompilesMySqlJsonQueries.php ├── Relations │ ├── Postgres │ │ ├── HasMany.php │ │ ├── HasOne.php │ │ ├── MorphOne.php │ │ ├── MorphMany.php │ │ ├── HasOneThrough.php │ │ ├── HasManyThrough.php │ │ ├── MorphOneOrMany.php │ │ ├── IsPostgresRelation.php │ │ ├── HasOneOrMany.php │ │ ├── BelongsTo.php │ │ └── HasOneOrManyThrough.php │ ├── Traits │ │ ├── Concatenation │ │ │ ├── IsConcatenableRelation.php │ │ │ ├── IsConcatenableBelongsToJsonRelation.php │ │ │ └── IsConcatenableHasManyJsonRelation.php │ │ ├── CompositeKeys │ │ │ ├── SupportsBelongsToJsonCompositeKeys.php │ │ │ └── SupportsHasManyJsonCompositeKeys.php │ │ └── IsJsonRelation.php │ ├── HasOneJson.php │ ├── InteractsWithPivotRecords.php │ ├── BelongsToJson.php │ └── HasManyJson.php ├── Casts │ └── Uuid.php ├── IdeHelperServiceProvider.php ├── IdeHelper │ └── JsonRelationsHook.php └── HasJsonRelationships.php ├── LICENSE ├── composer.json └── README.md /src/JsonKey.php: -------------------------------------------------------------------------------- 1 | column; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Grammars/MySqlGrammar.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class HasMany extends Base 14 | { 15 | /** @use \Staudenmeir\EloquentJsonRelations\Relations\Postgres\HasOneOrMany */ 16 | use HasOneOrMany; 17 | } 18 | -------------------------------------------------------------------------------- /src/Relations/Postgres/HasOne.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class HasOne extends Base 14 | { 15 | /** @use \Staudenmeir\EloquentJsonRelations\Relations\Postgres\HasOneOrMany */ 16 | use HasOneOrMany; 17 | } 18 | -------------------------------------------------------------------------------- /src/Relations/Postgres/MorphOne.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class MorphOne extends Base 14 | { 15 | /** @use \Staudenmeir\EloquentJsonRelations\Relations\Postgres\MorphOneOrMany */ 16 | use MorphOneOrMany; 17 | } 18 | -------------------------------------------------------------------------------- /src/Relations/Postgres/MorphMany.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class MorphMany extends Base 14 | { 15 | /** @use \Staudenmeir\EloquentJsonRelations\Relations\Postgres\MorphOneOrMany */ 16 | use MorphOneOrMany; 17 | } 18 | -------------------------------------------------------------------------------- /src/Grammars/MariaDbGrammar.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class HasOneThrough extends Base 15 | { 16 | /** @use \Staudenmeir\EloquentJsonRelations\Relations\Postgres\HasOneOrManyThrough */ 17 | use HasOneOrManyThrough; 18 | } 19 | -------------------------------------------------------------------------------- /src/Relations/Postgres/HasManyThrough.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class HasManyThrough extends Base 15 | { 16 | /** @use \Staudenmeir\EloquentJsonRelations\Relations\Postgres\HasOneOrManyThrough */ 17 | use HasOneOrManyThrough; 18 | } 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 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 | -------------------------------------------------------------------------------- /src/Casts/Uuid.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class Uuid implements CastsAttributes 14 | { 15 | /** 16 | * Cast the given value. 17 | * 18 | * @param \Illuminate\Database\Eloquent\Model $model 19 | * @param string $key 20 | * @param mixed $value 21 | * @param array $attributes 22 | * @return TGet|null 23 | */ 24 | public function get($model, $key, $value, $attributes) 25 | { 26 | return $value; 27 | } 28 | 29 | /** 30 | * Prepare the given value for storage. 31 | * 32 | * @param \Illuminate\Database\Eloquent\Model $model 33 | * @param string $key 34 | * @param TSet|null $value 35 | * @param array $attributes 36 | * @return mixed 37 | */ 38 | public function set($model, $key, $value, $attributes) 39 | { 40 | return $value; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Relations/Postgres/MorphOneOrMany.php: -------------------------------------------------------------------------------- 1 | */ 14 | use HasOneOrMany { 15 | getRelationExistenceQuery as getRelationExistenceQueryParent; 16 | } 17 | 18 | /** 19 | * Add the constraints for an internal relationship existence query. 20 | * 21 | * @param \Illuminate\Database\Eloquent\Builder $query 22 | * @param \Illuminate\Database\Eloquent\Builder $parentQuery 23 | * @param list|string $columns 24 | * @return \Illuminate\Database\Eloquent\Builder 25 | */ 26 | public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) 27 | { 28 | return $this->getRelationExistenceQueryParent($query, $parentQuery, $columns) 29 | ->where($this->morphType, $this->morphClass); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/IdeHelperServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->get('config'); 21 | 22 | $config->set( 23 | 'ide-helper.model_hooks', 24 | array_merge( 25 | [JsonRelationsHook::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 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "staudenmeir/eloquent-json-relations", 3 | "description": "Laravel Eloquent relationships with JSON keys", 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/eloquent-has-many-deep-contracts": "^1.3" 15 | }, 16 | "require-dev": { 17 | "barryvdh/laravel-ide-helper": "^3.0", 18 | "larastan/larastan": "^3.0", 19 | "laravel/framework": "^12.0", 20 | "mockery/mockery": "^1.5.1", 21 | "orchestra/testbench-core": "^10.0", 22 | "phpunit/phpunit": "^11.0", 23 | "staudenmeir/eloquent-has-many-deep": "^1.21" 24 | }, 25 | "autoload": { 26 | "psr-4": { 27 | "Staudenmeir\\EloquentJsonRelations\\": "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\\EloquentJsonRelations\\IdeHelperServiceProvider" 42 | ] 43 | } 44 | }, 45 | "minimum-stability": "dev", 46 | "prefer-stable": true 47 | } 48 | -------------------------------------------------------------------------------- /src/Relations/Postgres/IsPostgresRelation.php: -------------------------------------------------------------------------------- 1 | $query 16 | * @param \Illuminate\Database\Eloquent\Model $model 17 | * @param string $column 18 | * @param string $key 19 | * @return \Illuminate\Database\Query\Expression<*> 20 | */ 21 | protected function jsonColumn(Builder $query, Model $model, $column, $key) 22 | { 23 | $sql = $query->getQuery()->getGrammar()->wrap($column); 24 | 25 | if ($model->getKeyName() === $key && in_array($model->getKeyType(), ['int', 'integer'])) { 26 | $sql = '('.$sql.')::bigint'; 27 | } 28 | 29 | if ($model->hasCast($key) && $model->getCasts()[$key] === Uuid::class) { 30 | $sql = '('.$sql.')::uuid'; 31 | } 32 | 33 | return new Expression($sql); 34 | } 35 | 36 | /** 37 | * Get the name of the "where in" method for eager loading. 38 | * 39 | * @param \Illuminate\Database\Eloquent\Model $model 40 | * @param string $key 41 | * @return string 42 | */ 43 | protected function whereInMethod(Model $model, $key) 44 | { 45 | return 'whereIn'; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Relations/Traits/Concatenation/IsConcatenableRelation.php: -------------------------------------------------------------------------------- 1 | $query 17 | * @param list $models 18 | * @return void 19 | */ 20 | public function addEagerConstraintsToDeepRelationship(Builder $query, array $models): void 21 | { 22 | $this->addEagerConstraints($models); 23 | 24 | $this->mergeWhereConstraints($query, $this->query); 25 | } 26 | 27 | /** 28 | * Merge the where constraints from another query to the current query. 29 | * 30 | * @param \Illuminate\Database\Eloquent\Builder<*> $query 31 | * @param \Illuminate\Database\Eloquent\Builder<*> $from 32 | * @return \Illuminate\Database\Eloquent\Builder<*> 33 | */ 34 | public function mergeWhereConstraints(Builder $query, Builder $from): Builder 35 | { 36 | /** @var array $whereBindings */ 37 | $whereBindings = $from->getQuery()->getRawBindings()['where'] ?? []; 38 | 39 | $wheres = $from->getQuery()->wheres; 40 | 41 | $query->withoutGlobalScopes( 42 | $from->removedScopes() 43 | )->mergeWheres($wheres, $whereBindings); 44 | 45 | return $query; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Grammars/JsonGrammar.php: -------------------------------------------------------------------------------- 1 | $column 13 | * @return string 14 | */ 15 | public function compileJsonArray($column); 16 | 17 | /** 18 | * Compile a "JSON object" statement into SQL. 19 | * 20 | * @param string $column 21 | * @param int $levels 22 | * @return string 23 | */ 24 | public function compileJsonObject($column, $levels); 25 | 26 | /** 27 | * Compile a "JSON value select" statement into SQL. 28 | * 29 | * @param string $column 30 | * @return string 31 | */ 32 | public function compileJsonValueSelect(string $column): string; 33 | 34 | /** 35 | * Determine whether the database supports the "member of" operator. 36 | * 37 | * @param \Illuminate\Database\ConnectionInterface $connection 38 | * @return bool 39 | */ 40 | public function supportsMemberOf(ConnectionInterface $connection): bool; 41 | 42 | /** 43 | * Compile a "member of" statement into SQL. 44 | * 45 | * @param string $column 46 | * @param string|null $objectKey 47 | * @param mixed $value 48 | * @return string 49 | */ 50 | public function compileMemberOf(string $column, ?string $objectKey, mixed $value): string; 51 | 52 | /** 53 | * Prepare the bindings for a "member of" statement. 54 | * 55 | * @param mixed $value 56 | * @return list 57 | */ 58 | public function prepareBindingsForMemberOf(mixed $value): array; 59 | 60 | /** 61 | * Wrap a value in keyword identifiers. 62 | * 63 | * @param \Illuminate\Contracts\Database\Query\Expression|string $value 64 | * @return string 65 | */ 66 | public function wrap($value); 67 | } 68 | -------------------------------------------------------------------------------- /src/Grammars/SQLiteGrammar.php: -------------------------------------------------------------------------------- 1 | $column 15 | * @return string 16 | */ 17 | public function compileJsonArray($column) 18 | { 19 | return $this->wrap($column); 20 | } 21 | 22 | /** 23 | * Compile a "JSON object" statement into SQL. 24 | * 25 | * @param string $column 26 | * @param int $levels 27 | * @return string 28 | */ 29 | public function compileJsonObject($column, $levels) 30 | { 31 | throw new RuntimeException('This database is not supported.'); // @codeCoverageIgnore 32 | } 33 | 34 | /** 35 | * Compile a "JSON value select" statement into SQL. 36 | * 37 | * @param string $column 38 | * @return string 39 | */ 40 | public function compileJsonValueSelect(string $column): string 41 | { 42 | return $this->wrap($column); 43 | } 44 | 45 | /** 46 | * Determine whether the database supports the "member of" operator. 47 | * 48 | * @param \Illuminate\Database\ConnectionInterface $connection 49 | * @return bool 50 | */ 51 | public function supportsMemberOf(ConnectionInterface $connection): bool 52 | { 53 | return false; 54 | } 55 | 56 | /** 57 | * Compile a "member of" statement into SQL. 58 | * 59 | * @param string $column 60 | * @param string|null $objectKey 61 | * @param mixed $value 62 | * @return string 63 | */ 64 | public function compileMemberOf(string $column, ?string $objectKey, mixed $value): string 65 | { 66 | throw new RuntimeException('This database is not supported.'); // @codeCoverageIgnore 67 | } 68 | 69 | /** 70 | * Prepare the bindings for a "member of" statement. 71 | * 72 | * @param mixed $value 73 | * @return list 74 | */ 75 | public function prepareBindingsForMemberOf(mixed $value): array 76 | { 77 | throw new RuntimeException('This database is not supported.'); // @codeCoverageIgnore 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Relations/Postgres/HasOneOrMany.php: -------------------------------------------------------------------------------- 1 | $query 19 | * @param \Illuminate\Database\Eloquent\Builder $parentQuery 20 | * @param list|string $columns 21 | * @return \Illuminate\Database\Eloquent\Builder 22 | */ 23 | public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) 24 | { 25 | if ($query->getQuery()->from == $parentQuery->getQuery()->from) { 26 | return $this->getRelationExistenceQueryForSelfRelation($query, $parentQuery, $columns); 27 | } 28 | 29 | $second = $this->jsonColumn($query, $this->parent, $this->getExistenceCompareKey(), $this->localKey); 30 | 31 | $query->select($columns)->whereColumn( 32 | $this->getQualifiedParentKeyName(), 33 | '=', 34 | $second // @phpstan-ignore-line 35 | ); 36 | 37 | return $query; 38 | } 39 | 40 | /** 41 | * Add the constraints for a relationship query on the same table. 42 | * 43 | * @param \Illuminate\Database\Eloquent\Builder $query 44 | * @param \Illuminate\Database\Eloquent\Builder $parentQuery 45 | * @param list|string $columns 46 | * @return \Illuminate\Database\Eloquent\Builder 47 | */ 48 | public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder $parentQuery, $columns = ['*']) 49 | { 50 | $query->from($query->getModel()->getTable().' as '.$hash = $this->getRelationCountHash()); 51 | 52 | $query->getModel()->setTable($hash); 53 | 54 | $second = $this->jsonColumn($query, $this->parent, $hash.'.'.$this->getForeignKeyName(), $this->localKey); 55 | 56 | $query->select($columns)->whereColumn( 57 | $this->getQualifiedParentKeyName(), 58 | '=', 59 | $second // @phpstan-ignore-line 60 | ); 61 | 62 | return $query; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Grammars/SqlServerGrammar.php: -------------------------------------------------------------------------------- 1 | $column 15 | * @return string 16 | */ 17 | public function compileJsonArray($column) 18 | { 19 | return $this->wrap($column); 20 | } 21 | 22 | /** 23 | * Compile a "JSON object" statement into SQL. 24 | * 25 | * @param string $column 26 | * @param int $levels 27 | * @return string 28 | */ 29 | public function compileJsonObject($column, $levels) 30 | { 31 | throw new RuntimeException('This database is not supported.'); // @codeCoverageIgnore 32 | } 33 | 34 | /** 35 | * Compile a "JSON value select" statement into SQL. 36 | * 37 | * @param string $column 38 | * @return string 39 | */ 40 | public function compileJsonValueSelect(string $column): string 41 | { 42 | /** @var string $field */ 43 | /** @var string $path */ 44 | [$field, $path] = $this->wrapJsonFieldAndPath($column); 45 | 46 | return "json_query($field$path)"; 47 | } 48 | 49 | /** 50 | * Determine whether the database supports the "member of" operator. 51 | * 52 | * @param \Illuminate\Database\ConnectionInterface $connection 53 | * @return bool 54 | */ 55 | public function supportsMemberOf(ConnectionInterface $connection): bool 56 | { 57 | return false; 58 | } 59 | 60 | /** 61 | * Compile a "member of" statement into SQL. 62 | * 63 | * @param string $column 64 | * @param string|null $objectKey 65 | * @param mixed $value 66 | * @return string 67 | */ 68 | public function compileMemberOf(string $column, ?string $objectKey, mixed $value): string 69 | { 70 | throw new RuntimeException('This database is not supported.'); // @codeCoverageIgnore 71 | } 72 | 73 | /** 74 | * Prepare the bindings for a "member of" statement. 75 | * 76 | * @param mixed $value 77 | * @return list 78 | */ 79 | public function prepareBindingsForMemberOf(mixed $value): array 80 | { 81 | throw new RuntimeException('This database is not supported.'); // @codeCoverageIgnore 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Grammars/PostgresGrammar.php: -------------------------------------------------------------------------------- 1 | $column 16 | * @return string 17 | */ 18 | public function compileJsonArray($column) 19 | { 20 | return 'jsonb_build_array('.$this->wrap($column).')'; 21 | } 22 | 23 | /** 24 | * Compile a "JSON object" statement into SQL. 25 | * 26 | * @param string $column 27 | * @param int $levels 28 | * @return string 29 | */ 30 | public function compileJsonObject($column, $levels) 31 | { 32 | $sql = str_repeat('jsonb_build_object(?::text, ', $levels) 33 | .$this->wrap($column) 34 | .str_repeat(')', $levels); 35 | 36 | return $this->compileJsonArray(new Expression($sql)); 37 | } 38 | 39 | /** 40 | * Compile a "JSON value select" statement into SQL. 41 | * 42 | * @param string $column 43 | * @return string 44 | */ 45 | public function compileJsonValueSelect(string $column): string 46 | { 47 | return $this->wrap($column); 48 | } 49 | 50 | /** 51 | * Determine whether the database supports the "member of" operator. 52 | * 53 | * @param \Illuminate\Database\ConnectionInterface $connection 54 | * @return bool 55 | */ 56 | public function supportsMemberOf(ConnectionInterface $connection): bool 57 | { 58 | return false; 59 | } 60 | 61 | /** 62 | * Compile a "member of" statement into SQL. 63 | * 64 | * @param string $column 65 | * @param string|null $objectKey 66 | * @param mixed $value 67 | * @return string 68 | */ 69 | public function compileMemberOf(string $column, ?string $objectKey, mixed $value): string 70 | { 71 | throw new RuntimeException('This database is not supported.'); // @codeCoverageIgnore 72 | } 73 | 74 | /** 75 | * Prepare the bindings for a "member of" statement. 76 | * 77 | * @param mixed $value 78 | * @return list 79 | */ 80 | public function prepareBindingsForMemberOf(mixed $value): array 81 | { 82 | throw new RuntimeException('This database is not supported.'); // @codeCoverageIgnore 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Relations/Postgres/BelongsTo.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class BelongsTo extends Base 15 | { 16 | use IsPostgresRelation; 17 | 18 | /** 19 | * Add the constraints for an internal relationship existence query. 20 | * 21 | * @param \Illuminate\Database\Eloquent\Builder $query 22 | * @param \Illuminate\Database\Eloquent\Builder $parentQuery 23 | * @param list|string $columns 24 | * @return \Illuminate\Database\Eloquent\Builder 25 | */ 26 | public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) 27 | { 28 | if ($parentQuery->getQuery()->from == $query->getQuery()->from) { 29 | return $this->getRelationExistenceQueryForSelfRelation($query, $parentQuery, $columns); 30 | } 31 | 32 | $first = $this->jsonColumn($query, $this->related, $this->getQualifiedForeignKeyName(), $this->ownerKey); 33 | 34 | $query->select($columns)->whereColumn( 35 | $first, 36 | '=', 37 | $query->qualifyColumn($this->ownerKey) 38 | ); 39 | 40 | return $query; 41 | } 42 | 43 | /** 44 | * Add the constraints for a relationship query on the same table. 45 | * 46 | * @param \Illuminate\Database\Eloquent\Builder $query 47 | * @param \Illuminate\Database\Eloquent\Builder $parentQuery 48 | * @param list|string $columns 49 | * @return \Illuminate\Database\Eloquent\Builder 50 | */ 51 | public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder $parentQuery, $columns = ['*']) 52 | { 53 | $query->select($columns)->from( 54 | $query->getModel()->getTable().' as '.$hash = $this->getRelationCountHash() 55 | ); 56 | 57 | $query->getModel()->setTable($hash); 58 | 59 | $first = $this->jsonColumn($query, $this->related, $this->getQualifiedForeignKeyName(), $this->ownerKey); 60 | 61 | $query->whereColumn( 62 | $first, 63 | $hash.'.'.$this->ownerKey 64 | ); 65 | 66 | return $query; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/IdeHelper/JsonRelationsHook.php: -------------------------------------------------------------------------------- 1 | getMethods(ReflectionMethod::IS_PUBLIC); 30 | 31 | foreach ($methods as $method) { 32 | if ($method->isAbstract() || $method->isStatic() || !$method->isPublic() 33 | || $method->getNumberOfParameters() > 0 || $method->getDeclaringClass()->getName() === Model::class) { 34 | continue; 35 | } 36 | 37 | if ($method->getReturnType() instanceof ReflectionNamedType 38 | && in_array($method->getReturnType()->getName(), [BelongsToJson::class, HasManyJson::class, HasOneJson::class], true)) { 39 | /** @var \Illuminate\Database\Eloquent\Relations\Relation<*, *, *> $relationship */ 40 | $relationship = $method->invoke($model); 41 | 42 | $this->addRelationship($command, $method, $relationship); 43 | } 44 | } 45 | } 46 | 47 | /** 48 | * @param \Illuminate\Database\Eloquent\Relations\Relation<*, *, *> $relationship 49 | */ 50 | protected function addRelationship(ModelsCommand $command, ReflectionMethod $method, Relation $relationship): void 51 | { 52 | $type = '\\' . Collection::class . '|\\' . $relationship->getRelated()::class . '[]'; 53 | 54 | $command->setProperty( 55 | $method->getName(), 56 | $type, 57 | true, 58 | false 59 | ); 60 | 61 | $command->setProperty( 62 | Str::snake($method->getName()) . '_count', 63 | 'int', 64 | true, 65 | false, 66 | null, 67 | true 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Relations/HasOneJson.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class HasOneJson extends HasManyJson 17 | { 18 | use SupportsDefaultModels; 19 | 20 | /** @inheritDoc */ 21 | public function getResults() 22 | { 23 | if (is_null($this->getParentKey())) { 24 | return $this->getDefaultFor($this->parent); 25 | } 26 | 27 | return $this->first() ?: $this->getDefaultFor($this->parent); 28 | } 29 | 30 | /** @inheritDoc */ 31 | public function initRelation(array $models, $relation) 32 | { 33 | foreach ($models as $model) { 34 | $model->setRelation($relation, $this->getDefaultFor($model)); 35 | } 36 | 37 | return $models; 38 | } 39 | 40 | /** @inheritDoc */ 41 | public function match(array $models, Collection $results, $relation) 42 | { 43 | return $this->matchOne($models, $results, $relation); 44 | } 45 | 46 | /** @inheritDoc */ 47 | public function matchOne(array $models, Collection $results, $relation) 48 | { 49 | if ($this->hasCompositeKey()) { 50 | $this->matchWithCompositeKey($models, $results, $relation, 'one'); 51 | } else { 52 | HasOneOrMany::matchOneOrMany($models, $results, $relation, 'one'); 53 | } 54 | 55 | if ($this->key) { 56 | foreach ($models as $model) { 57 | /** @var TRelatedModel|null $relatedModel */ 58 | $relatedModel = $model->$relation; 59 | 60 | /** @var \Illuminate\Database\Eloquent\Collection $relatedModels */ 61 | $relatedModels = new Collection( 62 | array_filter([$relatedModel]) 63 | ); 64 | 65 | $model->setRelation( 66 | $relation, 67 | $this->hydratePivotRelation( 68 | $relatedModels, 69 | $model, 70 | fn (Model $model) => $model->{$this->getPathName()} 71 | )->first() 72 | ); 73 | } 74 | } 75 | 76 | return $models; 77 | } 78 | 79 | /** 80 | * Make a new related instance for the given model. 81 | * 82 | * @param TDeclaringModel $parent 83 | * @return TRelatedModel 84 | */ 85 | public function newRelatedInstanceFor(Model $parent) 86 | { 87 | return $this->related->newInstance(); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Grammars/Traits/CompilesMySqlJsonQueries.php: -------------------------------------------------------------------------------- 1 | $column 14 | * @return string 15 | */ 16 | public function compileJsonArray($column) 17 | { 18 | return 'json_array('.$this->wrap($column).')'; 19 | } 20 | 21 | /** 22 | * Compile a "JSON object" statement into SQL. 23 | * 24 | * @param string $column 25 | * @param int $levels 26 | * @return string 27 | */ 28 | public function compileJsonObject($column, $levels) 29 | { 30 | return str_repeat('json_object(?, ', $levels) 31 | .$this->wrap($column) 32 | .str_repeat(')', $levels); 33 | } 34 | 35 | /** 36 | * Compile a "JSON value select" statement into SQL. 37 | * 38 | * @param string $column 39 | * @return string 40 | */ 41 | public function compileJsonValueSelect(string $column): string 42 | { 43 | return $this->wrap($column); 44 | } 45 | 46 | /** 47 | * Determine whether the database supports the "member of" operator. 48 | * 49 | * @param \Illuminate\Database\ConnectionInterface $connection 50 | * @return bool 51 | */ 52 | public function supportsMemberOf(ConnectionInterface $connection): bool 53 | { 54 | /** @var \Illuminate\Database\MySqlConnection $connection */ 55 | 56 | if ($connection->isMaria()) { 57 | return false; 58 | } 59 | 60 | return version_compare($connection->getServerVersion(), '8.0.17') >= 0; 61 | } 62 | 63 | /** 64 | * Compile a "member of" statement into SQL. 65 | * 66 | * @param string $column 67 | * @param mixed $value 68 | * @return string 69 | */ 70 | public function compileMemberOf(string $column, ?string $objectKey, mixed $value): string 71 | { 72 | $columnWithKey = $objectKey ? $column . (str_contains($column, '->') ? '[*]' : '') . "->$objectKey" : $column; 73 | 74 | /** @var string $field */ 75 | /** @var string $path */ 76 | [$field, $path] = $this->wrapJsonFieldAndPath($columnWithKey); 77 | 78 | if ($objectKey && !str_contains($column, '->')) { 79 | $path = ", '$[*]" . substr($path, 4); 80 | } 81 | 82 | $sql = $path ? "json_extract($field$path)" : $field; 83 | 84 | if ($value instanceof Expression) { 85 | return $value->getValue($this) . " member of($sql)"; 86 | } 87 | 88 | return "? member of($sql)"; 89 | } 90 | 91 | /** 92 | * Prepare the bindings for a "member of" statement. 93 | * 94 | * @param mixed $value 95 | * @return list 96 | */ 97 | public function prepareBindingsForMemberOf(mixed $value): array 98 | { 99 | return $value instanceof Expression ? [] : [$value]; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Relations/Traits/Concatenation/IsConcatenableBelongsToJsonRelation.php: -------------------------------------------------------------------------------- 1 | */ 16 | use IsConcatenableRelation; 17 | 18 | /** 19 | * Append the relation's through parents, foreign and local keys to a deep relationship. 20 | * 21 | * @param non-empty-list $through 22 | * @param non-empty-list $foreignKeys 23 | * @param non-empty-list $localKeys 24 | * @param int $position 25 | * @return array{0: non-empty-list, 26 | * 1: non-empty-list, 27 | * 2: non-empty-list} 28 | */ 29 | public function appendToDeepRelationship(array $through, array $foreignKeys, array $localKeys, int $position): array 30 | { 31 | if ($position === 0) { 32 | $foreignKeys[] = $this->ownerKey; 33 | 34 | $localKeys[] = function (Builder $query, ?Builder $parentQuery = null) { 35 | if ($parentQuery) { 36 | $this->getRelationExistenceQuery($this->query, $parentQuery); 37 | } 38 | 39 | $this->mergeWhereConstraints($query, $this->query); 40 | }; 41 | } else { 42 | $foreignKeys[] = function (Builder $query, JoinClause $join) { 43 | $ownerKey = $this->query->qualifyColumn($this->ownerKey); 44 | 45 | [$sql, $bindings] = $this->relationExistenceQueryOwnerKey($query, $ownerKey); 46 | 47 | $query->addBinding($bindings, 'join'); 48 | 49 | $this->whereJsonContainsOrMemberOf( 50 | $join, 51 | $this->getQualifiedPath(), 52 | $query->getQuery()->connection->raw($sql) 53 | ); 54 | }; 55 | 56 | $localKeys[] = null; 57 | } 58 | 59 | return [$through, $foreignKeys, $localKeys]; 60 | } 61 | 62 | /** 63 | * Match the eagerly loaded results for a deep relationship to their parents. 64 | * 65 | * @param list $models 66 | * @param \Illuminate\Database\Eloquent\Collection $results 67 | * @param string $relation 68 | * @param string $type 69 | * @return list 70 | */ 71 | public function matchResultsForDeepRelationship( 72 | array $models, 73 | Collection $results, 74 | string $relation, 75 | string $type = 'many' 76 | ): array { 77 | $dictionary = $this->buildDictionaryForDeepRelationship($results); 78 | 79 | foreach ($models as $model) { 80 | $matches = []; 81 | 82 | foreach ($this->getForeignKeys($model) as $id) { 83 | if (isset($dictionary[$id])) { 84 | $matches = array_merge($matches, $dictionary[$id]); 85 | } 86 | } 87 | 88 | $value = $type === 'one' 89 | ? (reset($matches) ?: null) 90 | : $this->related->newCollection($matches); 91 | 92 | $model->setRelation($relation, $value); 93 | } 94 | 95 | return $models; 96 | } 97 | 98 | /** 99 | * Build the model dictionary for a deep relation. 100 | * 101 | * @param \Illuminate\Database\Eloquent\Collection $results 102 | * @return array> 103 | */ 104 | protected function buildDictionaryForDeepRelationship(Collection $results): array 105 | { 106 | $dictionary = []; 107 | 108 | foreach ($results as $result) { 109 | $dictionary[$result->laravel_through_key ?? null][] = $result; 110 | } 111 | 112 | return $dictionary; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Relations/Postgres/HasOneOrManyThrough.php: -------------------------------------------------------------------------------- 1 | |null $query 20 | * @return void 21 | */ 22 | protected function performJoin(?Builder $query = null) 23 | { 24 | $query = $query ?: $this->query; 25 | 26 | $farKey = $this->jsonColumn($query, $this->throughParent, $this->getQualifiedFarKeyName(), $this->secondLocalKey); 27 | 28 | $query->join($this->throughParent->getTable(), $this->getQualifiedParentKeyName(), '=', $farKey); 29 | 30 | if ($this->throughParentSoftDeletes() 31 | && method_exists($this->throughParent, 'getQualifiedDeletedAtColumn')) { 32 | /** @var string $deletedAtColumn */ 33 | $deletedAtColumn = $this->throughParent->getQualifiedDeletedAtColumn(); 34 | 35 | $query->whereNull($deletedAtColumn); 36 | } 37 | } 38 | 39 | /** 40 | * Add the constraints for an internal relationship existence query. 41 | * 42 | * @param \Illuminate\Database\Eloquent\Builder $query 43 | * @param \Illuminate\Database\Eloquent\Builder $parentQuery 44 | * @param list|string $columns 45 | * @return \Illuminate\Database\Eloquent\Builder 46 | */ 47 | public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) 48 | { 49 | if ($parentQuery->getQuery()->from === $query->getQuery()->from) { 50 | return $this->getRelationExistenceQueryForSelfRelation($query, $parentQuery, $columns); 51 | } 52 | 53 | if ($parentQuery->getQuery()->from === $this->throughParent->getTable()) { 54 | return $this->getRelationExistenceQueryForThroughSelfRelation($query, $parentQuery, $columns); 55 | } 56 | 57 | $this->performJoin($query); 58 | 59 | $firstKey = $this->jsonColumn($query, $this->farParent, $this->getQualifiedFirstKeyName(), $this->localKey); 60 | 61 | $query->select($columns)->whereColumn( 62 | $this->getQualifiedLocalKeyName(), 63 | '=', 64 | $firstKey // @phpstan-ignore-line 65 | ); 66 | 67 | return $query; 68 | } 69 | 70 | /** 71 | * Add the constraints for a relationship query on the same table. 72 | * 73 | * @param \Illuminate\Database\Eloquent\Builder $query 74 | * @param \Illuminate\Database\Eloquent\Builder $parentQuery 75 | * @param list|string $columns 76 | * @return \Illuminate\Database\Eloquent\Builder 77 | */ 78 | public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder $parentQuery, $columns = ['*']) 79 | { 80 | $query->from($query->getModel()->getTable().' as '.$hash = $this->getRelationCountHash()); 81 | 82 | $farKey = $this->jsonColumn($query, $this->throughParent, $hash.'.'.$this->secondKey, $this->secondLocalKey); 83 | 84 | $query->join($this->throughParent->getTable(), $this->getQualifiedParentKeyName(), '=', $farKey); 85 | 86 | $query->getModel()->setTable($hash); 87 | 88 | /** @var string $parentFrom */ 89 | $parentFrom = $parentQuery->getQuery()->from; 90 | 91 | $firstKey = $this->jsonColumn($query, $this->farParent, $this->getQualifiedFirstKeyName(), $this->localKey); 92 | 93 | $query->select($columns)->whereColumn( 94 | "$parentFrom.$this->localKey", 95 | '=', 96 | $firstKey // @phpstan-ignore-line 97 | ); 98 | 99 | return $query; 100 | } 101 | 102 | /** 103 | * Add the constraints for a relationship query on the same table as the through parent. 104 | * 105 | * @param \Illuminate\Database\Eloquent\Builder $query 106 | * @param \Illuminate\Database\Eloquent\Builder $parentQuery 107 | * @param list|string $columns 108 | * @return \Illuminate\Database\Eloquent\Builder 109 | */ 110 | public function getRelationExistenceQueryForThroughSelfRelation(Builder $query, Builder $parentQuery, $columns = ['*']) 111 | { 112 | $table = $this->throughParent->getTable().' as '.$hash = $this->getRelationCountHash(); 113 | 114 | $farKey = $this->jsonColumn($query, $this->throughParent, $this->getQualifiedFarKeyName(), $this->secondLocalKey); 115 | 116 | $query->join($table, $hash.'.'.$this->secondLocalKey, '=', $farKey); 117 | 118 | if ($this->throughParentSoftDeletes() 119 | && method_exists($this->throughParent, 'getDeletedAtColumn')) { 120 | /** @var string $deletedAtColumn */ 121 | $deletedAtColumn = $this->throughParent->getDeletedAtColumn(); 122 | 123 | $query->whereNull("$hash.$deletedAtColumn"); 124 | } 125 | 126 | /** @var string $parentFrom */ 127 | $parentFrom = $parentQuery->getQuery()->from; 128 | 129 | $firstKey = $this->jsonColumn($query, $this->farParent, $hash.'.'.$this->firstKey, $this->localKey); 130 | 131 | $query->select($columns)->whereColumn( 132 | "$parentFrom.$this->localKey", 133 | '=', 134 | $firstKey // @phpstan-ignore-line 135 | ); 136 | 137 | return $query; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/Relations/Traits/Concatenation/IsConcatenableHasManyJsonRelation.php: -------------------------------------------------------------------------------- 1 | */ 19 | use IsConcatenableRelation; 20 | 21 | /** 22 | * Append the relation's through parents, foreign and local keys to a deep relationship. 23 | * 24 | * @param non-empty-list $through 25 | * @param non-empty-list $foreignKeys 26 | * @param non-empty-list $localKeys 27 | * @param int $position 28 | * @return array{0: non-empty-list, 29 | * 1: non-empty-list, 30 | * 2: non-empty-list} 31 | */ 32 | public function appendToDeepRelationship(array $through, array $foreignKeys, array $localKeys, int $position): array 33 | { 34 | if ($position === 0) { 35 | $foreignKeys[] = function (Builder $query, ?Builder $parentQuery = null) { 36 | if ($parentQuery) { 37 | $this->getRelationExistenceQuery($this->query, $parentQuery); 38 | } 39 | 40 | $this->mergeWhereConstraints($query, $this->query); 41 | }; 42 | 43 | $localKeys[] = $this->localKey; 44 | } else { 45 | $foreignKeys[] = function (Builder $query, JoinClause $join) { 46 | [$sql, $bindings] = $this->relationExistenceQueryParentKey($query); 47 | 48 | $query->addBinding($bindings, 'join'); 49 | 50 | $this->whereJsonContainsOrMemberOf( 51 | $join, 52 | $this->getQualifiedPath(), 53 | $query->getQuery()->connection->raw($sql) 54 | ); 55 | }; 56 | 57 | $localKeys[] = null; 58 | } 59 | 60 | return [$through, $foreignKeys, $localKeys]; 61 | } 62 | 63 | /** 64 | * Get the custom through key for an eager load of the relation. 65 | * 66 | * @param string $alias 67 | * @return \Illuminate\Database\Query\Expression<*> 68 | */ 69 | public function getThroughKeyForDeepRelationships(string $alias): Expression 70 | { 71 | $throughKey = $this->getJsonGrammar($this->query)->compileJsonValueSelect($this->path); 72 | 73 | $alias = $this->query->getQuery()->grammar->wrap($alias); 74 | 75 | return new Expression("$throughKey as $alias"); 76 | } 77 | 78 | /** 79 | * Match the eagerly loaded results for a deep relationship to their parents. 80 | * 81 | * @param list $models 82 | * @param \Illuminate\Database\Eloquent\Collection $results 83 | * @param string $relation 84 | * @param string $type 85 | * @return list 86 | */ 87 | public function matchResultsForDeepRelationship( 88 | array $models, 89 | Collection $results, 90 | string $relation, 91 | string $type = 'many' 92 | ): array { 93 | $dictionary = $this->buildDictionaryForDeepRelationship($results); 94 | 95 | foreach ($models as $model) { 96 | $key = $this->getDictionaryKey($model->{$this->localKey}); 97 | 98 | if (isset($dictionary[$key])) { 99 | $value = $dictionary[$key]; 100 | 101 | $value = $type === 'one' 102 | ? (reset($value) ?: null) 103 | : $this->related->newCollection($value); 104 | 105 | $model->setRelation($relation, $value); 106 | } 107 | } 108 | 109 | return $models; 110 | } 111 | 112 | /** 113 | * Build the model dictionary for a deep relation. 114 | * 115 | * @param \Illuminate\Database\Eloquent\Collection $results 116 | * @param \Illuminate\Database\Eloquent\Collection $results 117 | * @return array> 118 | */ 119 | protected function buildDictionaryForDeepRelationship(Collection $results): array 120 | { 121 | $dictionary = []; 122 | 123 | $key = $this->key ? str_replace('->', '.', $this->key) : null; 124 | 125 | foreach ($results as $result) { 126 | /** @var string|null $throughKey */ 127 | $throughKey = $result->getAttribute('laravel_through_key'); 128 | 129 | /** @var array $values */ 130 | $values = json_decode($throughKey ?? '[]', true); 131 | 132 | if ($key) { 133 | $values = array_filter( 134 | Arr::pluck($values, $key), 135 | fn ($value) => $value !== null 136 | ); 137 | } 138 | 139 | foreach ($values as $value) { 140 | $dictionary[$value][] = $result; 141 | } 142 | } 143 | 144 | return $dictionary; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/Relations/Traits/CompositeKeys/SupportsBelongsToJsonCompositeKeys.php: -------------------------------------------------------------------------------- 1 | |string $foreignKey */ 24 | $foreignKey = $this->foreignKey; 25 | 26 | return is_array($foreignKey); 27 | } 28 | 29 | /** 30 | * Set the base constraints on the relation query for a composite key. 31 | * 32 | * @return void 33 | */ 34 | protected function addConstraintsWithCompositeKey(): void 35 | { 36 | /** @var list $ownerKey */ 37 | $ownerKey = $this->ownerKey; 38 | 39 | $columns = array_slice($ownerKey, 1); 40 | 41 | foreach ($columns as $column) { 42 | $this->query->where( 43 | $this->related->qualifyColumn($column), 44 | '=', 45 | $this->child->$column 46 | ); 47 | } 48 | } 49 | 50 | /** 51 | * Set the constraints for an eager load of the relation for a composite key. 52 | * 53 | * @param array $models 54 | * @return void 55 | */ 56 | protected function addEagerConstraintsWithCompositeKey(array $models): void 57 | { 58 | /** @var list $foreignKey */ 59 | $foreignKey = $this->foreignKey; 60 | 61 | /** @var list $ownerKey */ 62 | $ownerKey = $this->ownerKey; 63 | 64 | $keys = (new BaseCollection($models))->map( 65 | function (Model $model) use ($foreignKey) { 66 | return array_map( 67 | fn (string $column) => $model[$column], 68 | $foreignKey 69 | ); 70 | } 71 | )->values()->unique(null, true)->all(); 72 | 73 | $this->query->where( 74 | function (Builder $query) use ($keys, $ownerKey) { 75 | foreach ($keys as $key) { 76 | $query->orWhere( 77 | function (Builder $query) use ($key, $ownerKey) { 78 | foreach ($ownerKey as $i => $column) { 79 | if ($i === 0) { 80 | $query->whereIn( 81 | $this->related->qualifyColumn($column), 82 | $key[$i] 83 | ); 84 | } else { 85 | $query->where( 86 | $this->related->qualifyColumn($column), 87 | '=', 88 | $key[$i] 89 | ); 90 | } 91 | } 92 | } 93 | ); 94 | } 95 | } 96 | ); 97 | } 98 | 99 | /** 100 | * Match the eagerly loaded results to their parents for a composite key. 101 | * 102 | * @param array $models 103 | * @param \Illuminate\Database\Eloquent\Collection $results 104 | * @param string $relation 105 | * @return array 106 | */ 107 | protected function matchWithCompositeKey(array $models, Collection $results, string $relation): array 108 | { 109 | /** @var list $ownerKey */ 110 | $ownerKey = $this->ownerKey; 111 | 112 | $dictionary = $this->buildDictionaryWithCompositeKey($results); 113 | 114 | foreach ($models as $model) { 115 | $matches = []; 116 | 117 | $additionalValues = array_map( 118 | fn (string $key) => $model->$key, 119 | array_slice($ownerKey, 1) 120 | ); 121 | 122 | foreach ($this->getForeignKeys($model) as $id) { 123 | $values = $additionalValues; 124 | 125 | array_unshift($values, $id); 126 | 127 | $key = implode("\0", $values); 128 | 129 | $matches = array_merge($matches, $dictionary[$key] ?? []); 130 | } 131 | 132 | $collection = $this->related->newCollection($matches); 133 | 134 | $model->setRelation($relation, $collection); 135 | } 136 | 137 | return $models; 138 | } 139 | 140 | /** 141 | * Build model dictionary keyed by the relation's composite foreign key. 142 | * 143 | * @param \Illuminate\Database\Eloquent\Collection $results 144 | * @return array> 145 | */ 146 | protected function buildDictionaryWithCompositeKey(Collection $results): array 147 | { 148 | /** @var list $ownerKey */ 149 | $ownerKey = $this->ownerKey; 150 | 151 | $dictionary = []; 152 | 153 | foreach ($results as $result) { 154 | $values = array_map( 155 | fn (string $key) => $result->$key, 156 | $ownerKey 157 | ); 158 | 159 | $values = implode("\0", $values); 160 | 161 | $dictionary[$values][] = $result; 162 | } 163 | 164 | return $dictionary; 165 | } 166 | 167 | /** 168 | * Add the constraints for a relationship query for a composite key. 169 | * 170 | * @param \Illuminate\Database\Eloquent\Builder $query 171 | * @return void 172 | */ 173 | public function getRelationExistenceQueryWithCompositeKey(Builder $query): void 174 | { 175 | /** @var list $foreignKey */ 176 | $foreignKey = $this->foreignKey; 177 | 178 | $columns = array_slice($foreignKey, 1, preserve_keys: true); 179 | 180 | foreach ($columns as $i => $column) { 181 | $query->whereColumn( 182 | $this->child->qualifyColumn($column), 183 | '=', 184 | $query->qualifyColumn($this->ownerKey[$i]) 185 | ); 186 | } 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/Relations/Traits/IsJsonRelation.php: -------------------------------------------------------------------------------- 1 | $models 42 | * @param TDeclaringModel $parent 43 | * @param callable $callback 44 | * @return \Illuminate\Database\Eloquent\Collection 45 | */ 46 | public function hydratePivotRelation(Collection $models, Model $parent, callable $callback): Collection 47 | { 48 | foreach ($models as $i => $model) { 49 | $clone = clone $model; 50 | 51 | $models[$i] = $clone->setRelation( 52 | $this->getPivotAccessor(), 53 | $this->pivotRelation($clone, $parent, $callback) 54 | ); 55 | } 56 | 57 | return $models; 58 | } 59 | 60 | /** 61 | * Get the pivot relationship from the query. 62 | * 63 | * @param TRelatedModel $model 64 | * @param TDeclaringModel $parent 65 | * @param callable $callback 66 | * @return TRelatedModel 67 | */ 68 | protected function pivotRelation(Model $model, Model $parent, callable $callback) 69 | { 70 | /** @var list>|\Illuminate\Contracts\Support\Arrayable> $records */ 71 | $records = $callback($model, $parent); 72 | 73 | if ($records instanceof Arrayable) { 74 | $records = $records->toArray(); 75 | } 76 | 77 | $attributes = $this->pivotAttributes($model, $parent, $records); 78 | 79 | /** @var TRelatedModel $pivotModel */ 80 | $pivotModel = Pivot::fromAttributes($model, $attributes, null, true); // @phpstan-ignore-line 81 | 82 | return $pivotModel; 83 | } 84 | 85 | /** 86 | * Get the pivot attributes from a model. 87 | * 88 | * @param TRelatedModel $model 89 | * @param TDeclaringModel $parent 90 | * @param array> $records 91 | * @return array 92 | */ 93 | abstract public function pivotAttributes(Model $model, Model $parent, array $records); 94 | 95 | /** 96 | * Execute the query and get the first related model. 97 | * 98 | * @param list $columns 99 | * @return TRelatedModel|null 100 | */ 101 | public function first($columns = ['*']) 102 | { 103 | /** @var \Illuminate\Database\Eloquent\Collection $models */ 104 | $models = $this->take(1)->get($columns); 105 | 106 | return $models->first(); 107 | } 108 | 109 | /** 110 | * Get the fully qualified path of the relationship. 111 | * 112 | * @return string 113 | */ 114 | public function getQualifiedPath() 115 | { 116 | return $this->parent->qualifyColumn($this->path); 117 | } 118 | 119 | /** 120 | * Add a “where JSON contains” or "member of" clause to the query. 121 | * 122 | * @param \Illuminate\Contracts\Database\Query\Builder $query 123 | * @param string $column 124 | * @param mixed $value 125 | * @param callable|null $objectValueCallback 126 | * @param string $boolean 127 | * @return void 128 | */ 129 | protected function whereJsonContainsOrMemberOf( 130 | Builder $query, 131 | string $column, 132 | mixed $value, 133 | ?callable $objectValueCallback = null, 134 | string $boolean = 'and' 135 | ): void { 136 | $grammar = $this->getJsonGrammar($query); 137 | $connection = $query->getConnection(); 138 | 139 | if ($grammar->supportsMemberOf($connection)) { 140 | $query->whereRaw( 141 | $grammar->compileMemberOf($column, $this->key, $value), 142 | $grammar->prepareBindingsForMemberOf($value), 143 | $boolean 144 | ); 145 | } else { 146 | $value = $this->key && $objectValueCallback ? $objectValueCallback($value) : $value; 147 | 148 | $query->whereJsonContains($column, $value, $boolean); 149 | } 150 | } 151 | 152 | /** 153 | * Get the JSON grammar. 154 | * 155 | * @param \Illuminate\Contracts\Database\Query\Builder $query 156 | * @return \Staudenmeir\EloquentJsonRelations\Grammars\JsonGrammar 157 | */ 158 | protected function getJsonGrammar(Builder $query): JsonGrammar 159 | { 160 | /** @var \Illuminate\Database\Connection $connection */ 161 | $connection = $query->getConnection(); 162 | 163 | return match ($connection->getDriverName()) { 164 | 'mysql' => new MySqlGrammar($connection), 165 | 'mariadb' => new MariaDbGrammar($connection), 166 | 'pgsql' => new PostgresGrammar($connection), 167 | 'sqlite' => new SQLiteGrammar($connection), 168 | 'sqlsrv' => new SqlServerGrammar($connection), 169 | default => throw new RuntimeException('This database is not supported.') // @codeCoverageIgnore 170 | }; 171 | } 172 | 173 | /** 174 | * Get the name of the pivot accessor for this relationship. 175 | * 176 | * @return string 177 | */ 178 | public function getPivotAccessor(): string 179 | { 180 | return 'pivot'; 181 | } 182 | 183 | /** 184 | * Get the base path of the foreign key. 185 | * 186 | * @return string 187 | */ 188 | public function getForeignKeyPath(): string 189 | { 190 | return $this->path; 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/Relations/InteractsWithPivotRecords.php: -------------------------------------------------------------------------------- 1 | >|string|\Illuminate\Database\Eloquent\Collection|TRelatedModel|\Illuminate\Support\Collection $ids 20 | * @return TDeclaringModel 21 | */ 22 | public function attach($ids) 23 | { 24 | [$records, $others] = $this->decodeRecords(); 25 | 26 | $records = $this->formatIds( 27 | $this->parseIds($ids) 28 | ) + $records; 29 | 30 | $this->child->{$this->path} = $this->encodeRecords($records, $others); 31 | 32 | return $this->child; 33 | } 34 | 35 | /** 36 | * Detach models from the relationship. 37 | * 38 | * @param int|string|\Illuminate\Database\Eloquent\Collection|TRelatedModel|\Illuminate\Support\Collection $ids 39 | * @return TDeclaringModel 40 | */ 41 | public function detach($ids = null) 42 | { 43 | [$records, $others] = $this->decodeRecords(); 44 | 45 | if (!is_null($ids)) { 46 | /** @var list $parsedIds */ 47 | $parsedIds = $this->parseIds($ids); 48 | 49 | $records = array_diff_key( 50 | $records, 51 | array_flip($parsedIds) 52 | ); 53 | } else { 54 | $records = []; 55 | } 56 | 57 | $this->child->{$this->path} = $this->encodeRecords($records, $others); 58 | 59 | return $this->child; 60 | } 61 | 62 | /** 63 | * Sync the relationship with a list of models. 64 | * 65 | * @param int|array>|string|\Illuminate\Database\Eloquent\Collection|TRelatedModel|\Illuminate\Support\Collection $ids 66 | * @return TDeclaringModel 67 | */ 68 | public function sync($ids) 69 | { 70 | [, $others] = $this->decodeRecords(); 71 | 72 | $records = $this->formatIds( 73 | $this->parseIds($ids) 74 | ); 75 | 76 | $this->child->{$this->path} = $this->encodeRecords($records, $others); 77 | 78 | return $this->child; 79 | } 80 | 81 | /** 82 | * Toggle models from the relationship. 83 | * 84 | * @param int|array>|string|\Illuminate\Database\Eloquent\Collection|TRelatedModel|\Illuminate\Support\Collection $ids 85 | * @return TDeclaringModel 86 | */ 87 | public function toggle($ids) 88 | { 89 | [$records, $others] = $this->decodeRecords(); 90 | 91 | $ids = $this->formatIds( 92 | $this->parseIds($ids) 93 | ); 94 | 95 | $records = array_diff_key( 96 | $ids + $records, 97 | array_intersect_key($records, $ids) 98 | ); 99 | 100 | $this->child->{$this->path} = $this->encodeRecords($records, $others); 101 | 102 | return $this->child; 103 | } 104 | 105 | /** 106 | * Decode the records on the child model. 107 | * 108 | * @return array{0: array>, 1: list>} 109 | */ 110 | protected function decodeRecords() 111 | { 112 | $records = []; 113 | $others = []; 114 | 115 | $key = $this->key ? str_replace('->', '.', $this->key) : $this->key; 116 | 117 | foreach ((array) $this->child->{$this->path} as $record) { 118 | if (!is_array($record)) { 119 | $records[$record] = []; 120 | 121 | continue; 122 | } 123 | 124 | /** @var array $record */ 125 | 126 | $foreignKey = Arr::get($record, $key); 127 | 128 | if (!is_null($foreignKey)) { 129 | $records[$foreignKey] = $record; 130 | } else { 131 | $others[] = $record; 132 | } 133 | } 134 | 135 | return [$records, $others]; 136 | } 137 | 138 | /** 139 | * Encode the records for the child model. 140 | * 141 | * @param array> $records 142 | * @param list> $others 143 | * @return list>|list 144 | */ 145 | protected function encodeRecords(array $records, array $others) 146 | { 147 | if (!$this->key) { 148 | return array_keys($records); 149 | } 150 | 151 | $key = str_replace('->', '.', $this->key); 152 | 153 | foreach ($records as $id => &$attributes) { 154 | Arr::set($attributes, $key, $id); 155 | } 156 | 157 | return array_merge( 158 | array_values($records), 159 | $others 160 | ); 161 | } 162 | 163 | /** 164 | * Get all of the IDs from the given mixed value. 165 | * 166 | * @param int|array>|string|\Illuminate\Database\Eloquent\Collection|TRelatedModel|\Illuminate\Support\Collection $value 167 | * @return array>|list 168 | */ 169 | protected function parseIds($value) 170 | { 171 | if ($value instanceof Model) { 172 | /** @var int|string $id */ 173 | $id = $value->{$this->ownerKey}; 174 | 175 | return [$id]; 176 | } 177 | 178 | if ($value instanceof Collection) { 179 | /** @var \Illuminate\Support\Collection $ids */ 180 | $ids = $value->pluck($this->ownerKey); 181 | 182 | return $ids->all(); 183 | } 184 | 185 | if ($value instanceof BaseCollection) { 186 | /** @var list $ids */ 187 | $ids = $value->toArray(); 188 | 189 | return $ids; 190 | } 191 | 192 | return (array) $value; 193 | } 194 | 195 | /** 196 | * Format the parsed IDs. 197 | * 198 | * @param array>|list $ids 199 | * @return array> 200 | */ 201 | protected function formatIds(array $ids) 202 | { 203 | return (new BaseCollection($ids))->mapWithKeys(function ($attributes, $id) { 204 | if (!is_array($attributes)) { 205 | [$id, $attributes] = [$attributes, []]; 206 | } 207 | 208 | return [$id => $attributes]; 209 | })->all(); 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/Relations/Traits/CompositeKeys/SupportsHasManyJsonCompositeKeys.php: -------------------------------------------------------------------------------- 1 | |string $foreignKey */ 24 | $foreignKey = $this->foreignKey; 25 | 26 | return is_array($foreignKey); 27 | } 28 | 29 | /** 30 | * Set the base constraints on the relation query for a composite key. 31 | * 32 | * @return void 33 | */ 34 | protected function addConstraintsWithCompositeKey(): void 35 | { 36 | /** @var list $localKey */ 37 | $localKey = $this->localKey; 38 | 39 | $columns = array_slice($localKey, 1); 40 | 41 | foreach ($columns as $column) { 42 | $this->query->where( 43 | $this->related->qualifyColumn($column), 44 | '=', 45 | $this->parent->$column 46 | ); 47 | } 48 | } 49 | 50 | /** 51 | * Set the constraints for an eager load of the relation for a composite key. 52 | * 53 | * @param array $models 54 | * @return void 55 | */ 56 | protected function addEagerConstraintsWithCompositeKey(array $models): void 57 | { 58 | /** @var list $foreignKey */ 59 | $foreignKey = $this->foreignKey; 60 | 61 | /** @var list $localKey */ 62 | $localKey = $this->localKey; 63 | 64 | $keys = (new BaseCollection($models))->map( 65 | function (Model $model) use ($localKey) { 66 | return array_map( 67 | fn (string $column) => $model[$column], 68 | $localKey 69 | ); 70 | } 71 | )->values()->unique(null, true)->all(); 72 | 73 | $this->query->where( 74 | function (Builder $query) use ($foreignKey, $keys) { 75 | foreach ($keys as $key) { 76 | $query->orWhere( 77 | function (Builder $query) use ($foreignKey, $key) { 78 | foreach ($foreignKey as $i => $column) { 79 | if ($i === 0) { 80 | $this->whereJsonContainsOrMemberOf( 81 | $query, 82 | $this->path, 83 | $key[$i], 84 | fn ($parentKey) => $this->parentKeyToArray($parentKey) 85 | ); 86 | } else { 87 | $query->where( 88 | $this->related->qualifyColumn($column), 89 | '=', 90 | $key[$i] 91 | ); 92 | } 93 | } 94 | } 95 | ); 96 | } 97 | } 98 | ); 99 | } 100 | 101 | /** 102 | * Match the eagerly loaded results to their parents for a composite key. 103 | * 104 | * @param array $models 105 | * @param \Illuminate\Database\Eloquent\Collection $results 106 | * @param string $relation 107 | * @param string $type 108 | * @return array 109 | */ 110 | protected function matchWithCompositeKey(array $models, Collection $results, string $relation, string $type): array 111 | { 112 | /** @var list $localKey */ 113 | $localKey = $this->localKey; 114 | 115 | $dictionary = $this->buildDictionaryWithCompositeKey($results); 116 | 117 | foreach ($models as $model) { 118 | $values = array_map( 119 | fn ($key) => $model->$key, 120 | $localKey 121 | ); 122 | 123 | $key = implode("\0", $values); 124 | 125 | if (isset($dictionary[$key])) { 126 | $model->setRelation( 127 | $relation, 128 | $this->getRelationValue($dictionary, $key, $type) 129 | ); 130 | } 131 | } 132 | 133 | return $models; 134 | } 135 | 136 | /** 137 | * Build model dictionary keyed by the relation's composite foreign key. 138 | * 139 | * @param \Illuminate\Database\Eloquent\Collection $results 140 | * @return array> 141 | */ 142 | protected function buildDictionaryWithCompositeKey(Collection $results): array 143 | { 144 | $dictionary = []; 145 | 146 | $foreignKey = $this->getForeignKeyName(); 147 | 148 | $additionalColumns = $this->getAdditionalForeignKeyNames(); 149 | 150 | foreach ($results as $result) { 151 | $additionalValues = array_map( 152 | fn (string $column) => $result->getAttribute($column), 153 | $additionalColumns 154 | ); 155 | 156 | /** @var list $values */ 157 | $values = $result->$foreignKey; 158 | 159 | foreach($values as $value) { 160 | $values = [$value, ...$additionalValues]; 161 | 162 | $key = implode("\0", $values); 163 | 164 | $dictionary[$key][] = $result; 165 | } 166 | } 167 | 168 | return $dictionary; 169 | } 170 | 171 | /** 172 | * Add the constraints for a relationship query for a composite key. 173 | * 174 | * @param \Illuminate\Database\Eloquent\Builder $query 175 | * @return void 176 | */ 177 | public function getRelationExistenceQueryWithCompositeKey(Builder $query): void 178 | { 179 | $columns = $this->getAdditionalForeignKeyNames(); 180 | 181 | foreach ($columns as $i => $column) { 182 | $query->whereColumn( 183 | $this->parent->qualifyColumn($column), 184 | '=', 185 | $query->qualifyColumn($this->localKey[$i]) 186 | ); 187 | } 188 | } 189 | 190 | /** 191 | * Get the plain additional foreign keys. 192 | * 193 | * @return array 194 | */ 195 | protected function getAdditionalForeignKeyNames(): array 196 | { 197 | /** @var list $foreignKey */ 198 | $foreignKey = $this->foreignKey; 199 | 200 | $names = []; 201 | 202 | $columns = array_slice($foreignKey, 1, preserve_keys: true); 203 | 204 | foreach ($columns as $i => $column) { 205 | $segments = explode('.', $column); 206 | 207 | $names[$i] = end($segments); 208 | } 209 | 210 | return $names; 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/Relations/BelongsToJson.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class BelongsToJson extends BelongsTo implements ConcatenableRelation 23 | { 24 | /** @use \Staudenmeir\EloquentJsonRelations\Relations\InteractsWithPivotRecords */ 25 | use InteractsWithPivotRecords; 26 | /** @use \Staudenmeir\EloquentJsonRelations\Relations\Traits\Concatenation\IsConcatenableBelongsToJsonRelation */ 27 | use IsConcatenableBelongsToJsonRelation; 28 | /** @use \Staudenmeir\EloquentJsonRelations\Relations\Traits\IsJsonRelation */ 29 | use IsJsonRelation; 30 | /** @use \Staudenmeir\EloquentJsonRelations\Relations\Traits\CompositeKeys\SupportsBelongsToJsonCompositeKeys */ 31 | use SupportsBelongsToJsonCompositeKeys; 32 | 33 | /** 34 | * Create a new belongs to JSON relationship instance. 35 | * 36 | * @param \Illuminate\Database\Eloquent\Builder $query 37 | * @param TDeclaringModel $child 38 | * @param list|string $foreignKey 39 | * @param list|string $ownerKey 40 | * @param string $relationName 41 | * @return void 42 | */ 43 | public function __construct(Builder $query, Model $child, $foreignKey, $ownerKey, $relationName) 44 | { 45 | $segments = is_array($foreignKey) 46 | ? explode('[]->', $foreignKey[0]) 47 | : explode('[]->', $foreignKey); 48 | 49 | $this->path = $segments[0]; 50 | $this->key = $segments[1] ?? null; 51 | 52 | // @phpstan-ignore-next-line 53 | parent::__construct($query, $child, $foreignKey, $ownerKey, $relationName); 54 | } 55 | 56 | /** 57 | * Get the results of the relationship. 58 | * 59 | * @return mixed 60 | */ 61 | public function getResults() 62 | { 63 | return !empty($this->getForeignKeys()) 64 | ? $this->get() 65 | : $this->related->newCollection(); 66 | } 67 | 68 | /** 69 | * Execute the query as a "select" statement. 70 | * 71 | * @param list $columns 72 | * @return \Illuminate\Database\Eloquent\Collection 73 | */ 74 | public function get($columns = ['*']) 75 | { 76 | $models = parent::get($columns); 77 | 78 | if ($this->key && !empty($this->parent->{$this->path})) { 79 | $this->hydratePivotRelation( 80 | $models, 81 | $this->parent, 82 | fn (Model $model, Model $parent) => $parent->{$this->path} 83 | ); 84 | } 85 | 86 | return $models; 87 | } 88 | 89 | /** 90 | * Set the base constraints on the relation query. 91 | * 92 | * @return void 93 | */ 94 | public function addConstraints() 95 | { 96 | if (static::$constraints) { 97 | $table = $this->related->getTable(); 98 | 99 | $ownerKey = $this->hasCompositeKey() ? $this->ownerKey[0] : $this->ownerKey; 100 | 101 | $this->query->whereIn("$table.$ownerKey", $this->getForeignKeys()); 102 | 103 | if ($this->hasCompositeKey()) { 104 | $this->addConstraintsWithCompositeKey(); 105 | } 106 | } 107 | } 108 | 109 | /** @inheritDoc */ 110 | public function addEagerConstraints(array $models) 111 | { 112 | if ($this->hasCompositeKey()) { 113 | $this->addEagerConstraintsWithCompositeKey($models); 114 | 115 | return; 116 | } 117 | 118 | parent::addEagerConstraints($models); 119 | } 120 | 121 | /** 122 | * Gather the keys from an array of related models. 123 | * 124 | * @param array $models 125 | * @return list 126 | */ 127 | protected function getEagerModelKeys(array $models) 128 | { 129 | $keys = []; 130 | 131 | foreach ($models as $model) { 132 | $keys = array_merge($keys, $this->getForeignKeys($model)); 133 | } 134 | 135 | sort($keys); 136 | 137 | return array_values(array_unique($keys)); 138 | } 139 | 140 | /** @inheritDoc */ 141 | public function match(array $models, Collection $results, $relation) 142 | { 143 | if ($this->hasCompositeKey()) { 144 | $this->matchWithCompositeKey($models, $results, $relation); 145 | } else { 146 | $dictionary = $this->buildDictionary($results); 147 | 148 | foreach ($models as $model) { 149 | $matches = []; 150 | 151 | foreach ($this->getForeignKeys($model) as $id) { 152 | if (isset($dictionary[$id])) { 153 | $matches[] = $dictionary[$id]; 154 | } 155 | } 156 | 157 | $collection = $this->related->newCollection($matches); 158 | 159 | $model->setRelation($relation, $collection); 160 | } 161 | } 162 | 163 | foreach ($models as $model) { 164 | if ($this->key) { 165 | /** @var \Illuminate\Database\Eloquent\Collection $relatedModels */ 166 | $relatedModels = $model->getRelation($relation); 167 | 168 | $this->hydratePivotRelation( 169 | $relatedModels, 170 | $model, 171 | fn (Model $model, Model $parent) => $parent->{$this->path} 172 | ); 173 | } 174 | } 175 | 176 | return $models; 177 | } 178 | 179 | /** 180 | * Build model dictionary keyed by the relation's foreign key. 181 | * 182 | * @param \Illuminate\Database\Eloquent\Collection $results 183 | * @return array 184 | */ 185 | protected function buildDictionary(Collection $results) 186 | { 187 | $dictionary = []; 188 | 189 | foreach ($results as $result) { 190 | $dictionary[$result->{$this->ownerKey}] = $result; 191 | } 192 | 193 | return $dictionary; 194 | } 195 | 196 | /** @inheritDoc */ 197 | public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) 198 | { 199 | if ($parentQuery->getQuery()->from == $query->getQuery()->from) { 200 | return $this->getRelationExistenceQueryForSelfRelation($query, $parentQuery, $columns); 201 | } 202 | 203 | $ownerKey = $this->hasCompositeKey() ? $this->ownerKey[0] : $this->ownerKey; 204 | 205 | [$sql, $bindings] = $this->relationExistenceQueryOwnerKey($query, $ownerKey); 206 | 207 | $query->addBinding($bindings); 208 | 209 | $this->whereJsonContainsOrMemberOf( 210 | $query, 211 | $this->getQualifiedPath(), 212 | $query->getQuery()->connection->raw($sql) 213 | ); 214 | 215 | if ($this->hasCompositeKey()) { 216 | $this->getRelationExistenceQueryWithCompositeKey($query); 217 | } 218 | 219 | $query->select($columns); 220 | 221 | return $query; 222 | } 223 | 224 | /** @inheritDoc */ 225 | public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder $parentQuery, $columns = ['*']) 226 | { 227 | $query->from($query->getModel()->getTable().' as '.$hash = $this->getRelationCountHash()); 228 | 229 | $query->getModel()->setTable($hash); 230 | 231 | [$sql, $bindings] = $this->relationExistenceQueryOwnerKey($query, $hash.'.'.$this->ownerKey); 232 | 233 | $query->addBinding($bindings); 234 | 235 | $this->whereJsonContainsOrMemberOf( 236 | $query, 237 | $this->getQualifiedPath(), 238 | $query->getQuery()->connection->raw($sql) 239 | ); 240 | 241 | $query->select($columns); 242 | 243 | return $query; 244 | } 245 | 246 | /** 247 | * Get the owner key for the relationship query. 248 | * 249 | * @param \Illuminate\Database\Eloquent\Builder $query 250 | * @param string $ownerKey 251 | * @return array{0: string, 1: list} 252 | */ 253 | protected function relationExistenceQueryOwnerKey(Builder $query, string $ownerKey): array 254 | { 255 | $ownerKey = $query->qualifyColumn($ownerKey); 256 | 257 | $grammar = $this->getJsonGrammar($query); 258 | $connection = $query->getConnection(); 259 | 260 | if ($grammar->supportsMemberOf($connection)) { 261 | $sql = $grammar->wrap($ownerKey); 262 | 263 | $bindings = []; 264 | } else { 265 | if ($this->key) { 266 | $keys = explode('->', $this->key); 267 | 268 | $sql = $this->getJsonGrammar($query)->compileJsonObject($ownerKey, count($keys)); 269 | 270 | $bindings = $keys; 271 | } else { 272 | $sql = $this->getJsonGrammar($query)->compileJsonArray($ownerKey); 273 | 274 | $bindings = []; 275 | } 276 | } 277 | 278 | return [$sql, $bindings]; 279 | } 280 | 281 | /** 282 | * Get the pivot attributes from a model. 283 | * 284 | * @param TRelatedModel $model 285 | * @param TDeclaringModel $parent 286 | * @param array> $records 287 | * @return array 288 | */ 289 | public function pivotAttributes(Model $model, Model $parent, array $records) 290 | { 291 | /** @var string $key */ 292 | $key = $this->key; 293 | 294 | $key = str_replace('->', '.', $key); 295 | 296 | $ownerKey = $this->hasCompositeKey() ? $this->ownerKey[0] : $this->ownerKey; 297 | 298 | /** @var array $record */ 299 | $record = (new BaseCollection($records)) 300 | ->filter(function ($value) use ($key, $model, $ownerKey) { 301 | return Arr::get($value, $key) == $model->$ownerKey; 302 | })->first(); 303 | 304 | /** @var array $result */ 305 | $result = Arr::except($record, $key); 306 | 307 | return $result; 308 | } 309 | 310 | /** 311 | * Get the foreign key values. 312 | * 313 | * @param \Illuminate\Database\Eloquent\Model|null $model 314 | * @return array 315 | */ 316 | public function getForeignKeys(?Model $model = null) 317 | { 318 | $model = $model ?: $this->child; 319 | 320 | $foreignKey = $this->hasCompositeKey() ? $this->foreignKey[0] : $this->foreignKey; 321 | 322 | /** @var list $foreignKeys */ 323 | $foreignKeys = $model->$foreignKey; 324 | 325 | return (new BaseCollection($foreignKeys))->filter(fn ($key) => $key !== null)->all(); 326 | } 327 | 328 | /** 329 | * Get the related key for the relationship. 330 | * 331 | * @return string 332 | */ 333 | public function getRelatedKeyName() 334 | { 335 | return $this->ownerKey; 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /src/Relations/HasManyJson.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class HasManyJson extends HasMany implements ConcatenableRelation 23 | { 24 | /** @use \Staudenmeir\EloquentJsonRelations\Relations\Traits\Concatenation\IsConcatenableHasManyJsonRelation */ 25 | use IsConcatenableHasManyJsonRelation; 26 | /** @use \Staudenmeir\EloquentJsonRelations\Relations\Traits\IsJsonRelation */ 27 | use IsJsonRelation; 28 | /** @use \Staudenmeir\EloquentJsonRelations\Relations\Traits\CompositeKeys\SupportsHasManyJsonCompositeKeys */ 29 | use SupportsHasManyJsonCompositeKeys; 30 | 31 | /** 32 | * Create a new has many JSON relationship instance. 33 | * 34 | * @param \Illuminate\Database\Eloquent\Builder $query 35 | * @param TDeclaringModel $parent 36 | * @param list|string $foreignKey 37 | * @param list|string $localKey 38 | * @return void 39 | */ 40 | public function __construct(Builder $query, Model $parent, $foreignKey, $localKey) 41 | { 42 | $segments = is_array($foreignKey) 43 | ? explode('[]->', $foreignKey[0]) 44 | : explode('[]->', $foreignKey); 45 | 46 | $this->path = $segments[0]; 47 | $this->key = $segments[1] ?? null; 48 | 49 | // @phpstan-ignore-next-line 50 | parent::__construct($query, $parent, $foreignKey, $localKey); 51 | } 52 | 53 | /** 54 | * Get the results of the relationship. 55 | * 56 | * @return mixed 57 | */ 58 | public function getResults() 59 | { 60 | return !is_null($this->getParentKey()) 61 | ? $this->get() 62 | : $this->related->newCollection(); 63 | } 64 | 65 | /** 66 | * Execute the query as a "select" statement. 67 | * 68 | * @param list $columns 69 | * @return \Illuminate\Database\Eloquent\Collection 70 | */ 71 | public function get($columns = ['*']) 72 | { 73 | $models = parent::get($columns); 74 | 75 | $localKey = $this->hasCompositeKey() ? $this->localKey[0] : $this->localKey; 76 | 77 | if ($this->key && !is_null($this->parent->$localKey)) { 78 | $this->hydratePivotRelation( 79 | $models, 80 | $this->parent, 81 | fn (Model $model) => $model->{$this->getPathName()} 82 | ); 83 | } 84 | 85 | return $models; 86 | } 87 | 88 | /** 89 | * Set the base constraints on the relation query. 90 | * 91 | * @return void 92 | */ 93 | public function addConstraints() 94 | { 95 | if (static::$constraints) { 96 | $parentKey = $this->getParentKey(); 97 | 98 | $this->whereJsonContainsOrMemberOf( 99 | $this->query, 100 | $this->path, 101 | $parentKey, 102 | fn ($parentKey) => $this->parentKeyToArray($parentKey) 103 | ); 104 | 105 | if ($this->hasCompositeKey()) { 106 | $this->addConstraintsWithCompositeKey(); 107 | } 108 | } 109 | } 110 | 111 | /** @inheritDoc */ 112 | public function addEagerConstraints(array $models) 113 | { 114 | if ($this->hasCompositeKey()) { 115 | $this->addEagerConstraintsWithCompositeKey($models); 116 | 117 | return; 118 | } 119 | 120 | $parentKeys = $this->getKeys($models, $this->localKey); 121 | 122 | $this->query->where(function (Builder $query) use ($parentKeys) { 123 | foreach ($parentKeys as $parentKey) { 124 | $this->whereJsonContainsOrMemberOf( 125 | $query, 126 | $this->path, 127 | $parentKey, 128 | fn ($parentKey) => $this->parentKeyToArray($parentKey), 129 | 'or' 130 | ); 131 | } 132 | }); 133 | } 134 | 135 | /** 136 | * Embed a parent key in a nested array. 137 | * 138 | * @param mixed $parentKey 139 | * @return array> 140 | */ 141 | protected function parentKeyToArray($parentKey) 142 | { 143 | /** @var string $key */ 144 | $key = $this->key; 145 | 146 | $keys = explode('->', $key); 147 | 148 | foreach (array_reverse($keys) as $key) { 149 | $parentKey = [$key => $parentKey]; 150 | } 151 | 152 | return [$parentKey]; 153 | } 154 | 155 | /** @inheritDoc */ 156 | protected function matchOneOrMany(array $models, Collection $results, $relation, $type) 157 | { 158 | if ($this->hasCompositeKey()) { 159 | $this->matchWithCompositeKey($models, $results, $relation, 'many'); 160 | } else { 161 | parent::matchOneOrMany($models, $results, $relation, $type); 162 | } 163 | 164 | if ($this->key) { 165 | foreach ($models as $model) { 166 | /** @var \Illuminate\Database\Eloquent\Collection $relatedModels */ 167 | $relatedModels = $model->$relation; 168 | 169 | $this->hydratePivotRelation( 170 | $relatedModels, 171 | $model, 172 | fn (Model $model) => $model->{$this->getPathName()} 173 | ); 174 | } 175 | } 176 | 177 | return $models; 178 | } 179 | 180 | /** @inheritDoc */ 181 | protected function buildDictionary(Collection $results) 182 | { 183 | $foreign = $this->getForeignKeyName(); 184 | 185 | $dictionary = []; 186 | 187 | foreach ($results as $result) { 188 | /** @var list $foreignKeys */ 189 | $foreignKeys = $result->$foreign; 190 | 191 | foreach ($foreignKeys as $value) { 192 | $dictionary[$value][] = $result; 193 | } 194 | } 195 | 196 | return $dictionary; 197 | } 198 | 199 | /** 200 | * Set the foreign ID for creating a related model. 201 | * 202 | * @param \Illuminate\Database\Eloquent\Model $model 203 | * @return void 204 | */ 205 | protected function setForeignAttributesForCreate(Model $model) 206 | { 207 | $foreignKey = explode('.', $this->foreignKey)[1]; 208 | 209 | if (method_exists($model, 'belongsToJson')) { 210 | /** @var \Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson<*, *> $relation */ 211 | $relation = $model->belongsToJson(get_class($this->parent), $foreignKey, $this->localKey); 212 | 213 | $relation->attach($this->getParentKey()); 214 | } 215 | } 216 | 217 | /** @inheritDoc */ 218 | public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) 219 | { 220 | if ($query->getQuery()->from == $parentQuery->getQuery()->from) { 221 | return $this->getRelationExistenceQueryForSelfRelation($query, $parentQuery, $columns); 222 | } 223 | 224 | [$sql, $bindings] = $this->relationExistenceQueryParentKey($query); 225 | 226 | $query->addBinding($bindings); 227 | 228 | $this->whereJsonContainsOrMemberOf( 229 | $query, 230 | $this->getQualifiedPath(), 231 | $query->getQuery()->connection->raw($sql) 232 | ); 233 | 234 | if ($this->hasCompositeKey()) { 235 | $this->getRelationExistenceQueryWithCompositeKey($query); 236 | } 237 | 238 | 239 | $query->select($columns); 240 | 241 | return $query; 242 | } 243 | 244 | /** @inheritDoc */ 245 | public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder $parentQuery, $columns = ['*']) 246 | { 247 | $query->from($query->getModel()->getTable() . ' as ' . $hash = $this->getRelationCountHash()); 248 | 249 | $query->getModel()->setTable($hash); 250 | 251 | [$sql, $bindings] = $this->relationExistenceQueryParentKey($query); 252 | 253 | $query->addBinding($bindings); 254 | 255 | $this->whereJsonContainsOrMemberOf( 256 | $query, 257 | $hash . '.' . $this->getPathName(), 258 | $query->getQuery()->connection->raw($sql) 259 | ); 260 | 261 | 262 | $query->select($columns); 263 | 264 | return $query; 265 | } 266 | 267 | /** 268 | * Get the parent key for the relationship query. 269 | * 270 | * @param \Illuminate\Database\Eloquent\Builder $query 271 | * @return array{0: string, 1: list} 272 | */ 273 | protected function relationExistenceQueryParentKey(Builder $query): array 274 | { 275 | $parentKey = $this->getQualifiedParentKeyName(); 276 | 277 | $grammar = $this->getJsonGrammar($query); 278 | $connection = $query->getConnection(); 279 | 280 | if ($grammar->supportsMemberOf($connection)) { 281 | $sql = $grammar->wrap($parentKey); 282 | 283 | $bindings = []; 284 | } else { 285 | if ($this->key) { 286 | $keys = explode('->', $this->key); 287 | 288 | $sql = $this->getJsonGrammar($query)->compileJsonObject($parentKey, count($keys)); 289 | 290 | $bindings = $keys; 291 | } else { 292 | $sql = $this->getJsonGrammar($query)->compileJsonArray($parentKey); 293 | 294 | $bindings = []; 295 | } 296 | } 297 | 298 | return [$sql, $bindings]; 299 | } 300 | 301 | /** 302 | * Get the pivot attributes from a model. 303 | * 304 | * @param TRelatedModel $model 305 | * @param TDeclaringModel $parent 306 | * @param array> $records 307 | * @return array 308 | */ 309 | public function pivotAttributes(Model $model, Model $parent, array $records) 310 | { 311 | /** @var string $key */ 312 | $key = $this->key; 313 | 314 | $key = str_replace('->', '.', $key); 315 | 316 | $localKey = $this->hasCompositeKey() ? $this->localKey[0] : $this->localKey; 317 | 318 | /** @var array $record */ 319 | $record = (new BaseCollection($records)) 320 | ->filter(function ($value) use ($key, $localKey, $parent) { 321 | return Arr::get($value, $key) == $parent->$localKey; 322 | })->first(); 323 | 324 | /** @var array $result */ 325 | $result = Arr::except($record, $key); 326 | 327 | return $result; 328 | } 329 | 330 | /** 331 | * Get the plain path name. 332 | * 333 | * @return string 334 | */ 335 | public function getPathName() 336 | { 337 | /** @var string $pathName */ 338 | $pathName = last( 339 | explode('.', $this->path) 340 | ); 341 | 342 | return $pathName; 343 | } 344 | 345 | /** 346 | * Get the key value of the parent's local key. 347 | * 348 | * @return mixed 349 | */ 350 | public function getParentKey() 351 | { 352 | $localKey = $this->hasCompositeKey() ? $this->localKey[0] : $this->localKey; 353 | 354 | return $this->parent->getAttribute($localKey); 355 | } 356 | 357 | /** 358 | * Get the fully qualified parent key name. 359 | * 360 | * @return string 361 | */ 362 | public function getQualifiedParentKeyName() 363 | { 364 | $localKey = $this->hasCompositeKey() ? $this->localKey[0] : $this->localKey; 365 | 366 | return $this->parent->qualifyColumn($localKey); 367 | } 368 | 369 | /** 370 | * Get the foreign key for the relationship. 371 | * 372 | * @return string 373 | */ 374 | public function getQualifiedForeignKeyName() 375 | { 376 | return $this->hasCompositeKey() ? $this->foreignKey[0] : $this->foreignKey; 377 | } 378 | } 379 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Eloquent JSON Relations 2 | 3 | [![CI](https://github.com/staudenmeir/eloquent-json-relations/actions/workflows/ci.yml/badge.svg)](https://github.com/staudenmeir/eloquent-json-relations/actions/workflows/ci.yml?query=branch%3Amain) 4 | [![Code Coverage](https://codecov.io/gh/staudenmeir/eloquent-json-relations/graph/badge.svg?token=T41IX53I5U)](https://codecov.io/gh/staudenmeir/eloquent-json-relations) 5 | [![PHPStan](https://img.shields.io/badge/PHPStan-level%2010-brightgreen.svg?style=flat)](https://github.com/staudenmeir/eloquent-json-relations/actions/workflows/static-analysis.yml?query=branch%3Amain) 6 | [![Latest Stable Version](https://poser.pugx.org/staudenmeir/eloquent-json-relations/v/stable)](https://packagist.org/packages/staudenmeir/eloquent-json-relations) 7 | [![Total Downloads](https://poser.pugx.org/staudenmeir/eloquent-json-relations/downloads)](https://packagist.org/packages/staudenmeir/eloquent-json-relations/stats) 8 | [![License](https://poser.pugx.org/staudenmeir/eloquent-json-relations/license)](https://github.com/staudenmeir/eloquent-json-relations/blob/main/LICENSE) 9 | 10 | This Laravel Eloquent extension adds support for JSON foreign keys to `BelongsTo`, `HasOne`, `HasMany`, `HasOneThrough` 11 | , `HasManyThrough`, `MorphTo`, `MorphOne` and `MorphMany` relationships. 12 | 13 | It also provides [many-to-many](#many-to-many-relationships) and [has-many-through](#has-many-through-relationships) 14 | relationships with JSON arrays. 15 | 16 | ## Compatibility 17 | 18 | - MySQL 5.7+ 19 | - MariaDB 10.2+ 20 | - PostgreSQL 9.3+ 21 | - SQLite 3.38+ 22 | - SQL Server 2016+ 23 | 24 | ## Installation 25 | 26 | composer require "staudenmeir/eloquent-json-relations:^1.1" 27 | 28 | Use this command if you are in PowerShell on Windows (e.g. in VS Code): 29 | 30 | composer require "staudenmeir/eloquent-json-relations:^^^^1.1" 31 | 32 | ## Versions 33 | 34 | | Laravel | Package | 35 | |:--------|:--------| 36 | | 12.x | 1.14 | 37 | | 11.x | 1.11 | 38 | | 10.x | 1.8 | 39 | | 9.x | 1.7 | 40 | | 8.x | 1.6 | 41 | | 7.x | 1.5 | 42 | | 6.x | 1.4 | 43 | | 5.8 | 1.3 | 44 | | 5.5–5.7 | 1.2 | 45 | 46 | ## Usage 47 | 48 | - [One-To-Many Relationships](#one-to-many-relationships) 49 | - [Referential Integrity](#referential-integrity) 50 | - [Many-To-Many Relationships](#many-to-many-relationships) 51 | - [Array of IDs](#array-of-ids) 52 | - [Array of Objects](#array-of-objects) 53 | - [HasOneJson](#hasonejson) 54 | - [Composite Keys](#composite-keys) 55 | - [Query Performance](#query-performance) 56 | - [Has-Many-Through Relationships](#has-many-through-relationships) 57 | - [Deep Relationship Concatenation](#deep-relationship-concatenation) 58 | 59 | ### One-To-Many Relationships 60 | 61 | In this example, `User` has a `BelongsTo` relationship with `Locale`. There is no dedicated column, but the foreign 62 | key (`locale_id`) is stored as a property in a JSON field (`users.options`): 63 | 64 | ```php 65 | class User extends Model 66 | { 67 | use \Staudenmeir\EloquentJsonRelations\HasJsonRelationships; 68 | 69 | protected $casts = [ 70 | 'options' => 'json', 71 | ]; 72 | 73 | public function locale() 74 | { 75 | return $this->belongsTo(Locale::class, 'options->locale_id'); 76 | } 77 | } 78 | 79 | class Locale extends Model 80 | { 81 | use \Staudenmeir\EloquentJsonRelations\HasJsonRelationships; 82 | 83 | public function users() 84 | { 85 | return $this->hasMany(User::class, 'options->locale_id'); 86 | } 87 | } 88 | ``` 89 | 90 | Remember to use the `HasJsonRelationships` trait in both the parent and the related model. 91 | 92 | #### Referential Integrity 93 | 94 | On [MySQL](https://dev.mysql.com/doc/refman/en/create-table-foreign-keys.html), 95 | [MariaDB](https://mariadb.com/kb/en/library/foreign-keys/), 96 | and [SQL Server](https://docs.microsoft.com/en-us/sql/relational-databases/tables/specify-computed-columns-in-a-table) 97 | you can still ensure referential integrity with foreign keys on generated/computed columns. 98 | 99 | Laravel migrations support this feature on MySQL/MariaDB: 100 | 101 | ```php 102 | Schema::create('users', function (Blueprint $table) { 103 | $table->bigIncrements('id'); 104 | $table->json('options'); 105 | $locale_id = DB::connection()->getQueryGrammar()->wrap('options->locale_id'); 106 | $table->unsignedBigInteger('locale_id')->storedAs($locale_id); 107 | $table->foreign('locale_id')->references('id')->on('locales'); 108 | }); 109 | ``` 110 | 111 | Laravel migrations also support this feature on SQL Server: 112 | 113 | ```php 114 | Schema::create('users', function (Blueprint $table) { 115 | $table->bigIncrements('id'); 116 | $table->json('options'); 117 | $locale_id = DB::connection()->getQueryGrammar()->wrap('options->locale_id'); 118 | $locale_id = 'CAST('.$locale_id.' AS INT)'; 119 | $table->computed('locale_id', $locale_id)->persisted(); 120 | $table->foreign('locale_id')->references('id')->on('locales'); 121 | }); 122 | ``` 123 | 124 | ### Many-To-Many Relationships 125 | 126 | The package also introduces two new relationship types: `BelongsToJson` and `HasManyJson` 127 | 128 | Use them to implement many-to-many relationships with JSON arrays. 129 | 130 | In this example, `User` has a `BelongsToMany` relationship with `Role`. There is no pivot table, but the foreign keys 131 | are stored as an array in a JSON field (`users.options`): 132 | 133 | #### Array of IDs 134 | 135 | By default, the relationship stores pivot records as an array of IDs: 136 | 137 | ```php 138 | class User extends Model 139 | { 140 | use \Staudenmeir\EloquentJsonRelations\HasJsonRelationships; 141 | 142 | protected $casts = [ 143 | 'options' => 'json', 144 | ]; 145 | 146 | public function roles(): \Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson 147 | { 148 | return $this->belongsToJson(Role::class, 'options->role_ids'); 149 | } 150 | } 151 | 152 | class Role extends Model 153 | { 154 | use \Staudenmeir\EloquentJsonRelations\HasJsonRelationships; 155 | 156 | public function users(): \Staudenmeir\EloquentJsonRelations\Relations\HasManyJson 157 | { 158 | return $this->hasManyJson(User::class, 'options->role_ids'); 159 | } 160 | } 161 | ``` 162 | 163 | On the side of the `BelongsToJson` relationship, you can use `attach()`, `detach()`, `sync()` and `toggle()`: 164 | 165 | ```php 166 | $user = new User; 167 | $user->roles()->attach([1, 2])->save(); // Now: [1, 2] 168 | 169 | $user->roles()->detach([2])->save(); // Now: [1] 170 | 171 | $user->roles()->sync([1, 3])->save(); // Now: [1, 3] 172 | 173 | $user->roles()->toggle([2, 3])->save(); // Now: [1, 2] 174 | ``` 175 | 176 | #### Array of Objects 177 | 178 | You can also store pivot records as objects with additional attributes: 179 | 180 | ```php 181 | class User extends Model 182 | { 183 | use \Staudenmeir\EloquentJsonRelations\HasJsonRelationships; 184 | 185 | protected $casts = [ 186 | 'options' => 'json', 187 | ]; 188 | 189 | public function roles(): \Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson 190 | { 191 | return $this->belongsToJson(Role::class, 'options->roles[]->role_id'); 192 | } 193 | } 194 | 195 | class Role extends Model 196 | { 197 | use \Staudenmeir\EloquentJsonRelations\HasJsonRelationships; 198 | 199 | public function users(): \Staudenmeir\EloquentJsonRelations\Relations\HasManyJson 200 | { 201 | return $this->hasManyJson(User::class, 'options->roles[]->role_id'); 202 | } 203 | } 204 | ``` 205 | 206 | Here, `options->roles` is the path to the JSON array. `role_id` is the name of the foreign key property inside the 207 | record object: 208 | 209 | ```php 210 | $user = new User; 211 | $user->roles()->attach([1 => ['active' => true], 2 => ['active' => false]])->save(); 212 | // Now: [{"role_id":1,"active":true},{"role_id":2,"active":false}] 213 | 214 | $user->roles()->detach([2])->save(); 215 | // Now: [{"role_id":1,"active":true}] 216 | 217 | $user->roles()->sync([1 => ['active' => false], 3 => ['active' => true]])->save(); 218 | // Now: [{"role_id":1,"active":false},{"role_id":3,"active":true}] 219 | 220 | $user->roles()->toggle([2 => ['active' => true], 3])->save(); 221 | // Now: [{"role_id":1,"active":false},{"role_id":2,"active":true}] 222 | ``` 223 | 224 | **Limitations:** On SQLite and SQL Server, these relationships only work partially. 225 | 226 | #### HasOneJson 227 | 228 | Define a `HasOneJson` relationship if you only want to retrieve a single related instance: 229 | 230 | ```php 231 | class Role extends Model 232 | { 233 | use \Staudenmeir\EloquentJsonRelations\HasJsonRelationships; 234 | 235 | public function latestUser(): \Staudenmeir\EloquentJsonRelations\Relations\HasOneJson 236 | { 237 | return $this->hasOneJson(User::class, 'options->roles[]->role_id') 238 | ->latest(); 239 | } 240 | } 241 | ``` 242 | 243 | #### Composite Keys 244 | 245 | If multiple columns need to match, you can define a composite key. 246 | 247 | Pass an array of keys that starts with JSON key: 248 | 249 | ```php 250 | class Employee extends Model 251 | { 252 | public function tasks(): \Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson 253 | { 254 | return $this->belongsToJson( 255 | Task::class, 256 | ['options->work_stream_ids', 'team_id'], 257 | ['work_stream_id', 'team_id'] 258 | ); 259 | } 260 | } 261 | 262 | class Task extends Model 263 | { 264 | public function employees(): \Staudenmeir\EloquentJsonRelations\Relations\HasManyJson 265 | { 266 | return $this->hasManyJson( 267 | Employee::class, 268 | ['options->work_stream_ids', 'team_id'], 269 | ['work_stream_id', 'team_id'] 270 | ); 271 | } 272 | } 273 | ``` 274 | 275 | #### Query Performance 276 | 277 | ##### MySQL 278 | 279 | On MySQL 8.0.17+, you can improve the query performance 280 | with [multi-valued indexes](https://dev.mysql.com/doc/refman/8.0/en/create-index.html#create-index-multi-valued). 281 | 282 | Use this migration when the array is the column itself (e.g. `users.role_ids`): 283 | 284 | ```php 285 | Schema::create('users', function (Blueprint $table) { 286 | // ... 287 | 288 | // Array of IDs 289 | $table->rawIndex('(cast(`role_ids` as unsigned array))', 'users_role_ids_index'); 290 | 291 | // Array of objects 292 | $table->rawIndex('(cast(`roles`->\'$[*]."role_id"\' as unsigned array))', 'users_roles_index'); 293 | }); 294 | ``` 295 | 296 | Use this migration when the array is nested inside an object (e.g. `users.options->role_ids`): 297 | 298 | ```php 299 | Schema::create('users', function (Blueprint $table) { 300 | // ... 301 | 302 | // Array of IDs 303 | $table->rawIndex('(cast(`options`->\'$."role_ids"\' as unsigned array))', 'users_role_ids_index'); 304 | 305 | // Array of objects 306 | $table->rawIndex('(cast(`options`->\'$."roles"[*]."role_id"\' as unsigned array))', 'users_roles_index'); 307 | }); 308 | ``` 309 | 310 | MySQL is quite picky about the syntax so I recommend that you check once 311 | with [`EXPLAIN`](https://dev.mysql.com/doc/refman/8.0/en/using-explain.html) that the executed relationship queries 312 | actually use the index. 313 | 314 | ##### PostgreSQL 315 | 316 | On PostgreSQL, you can improve the query performance with `jsonb` columns 317 | and [`GIN` indexes](https://www.postgresql.org/docs/current/datatype-json.html#JSON-INDEXING). 318 | 319 | Use this migration when the array of IDs/objects is the column itself (e.g. `users.role_ids`): 320 | 321 | ```php 322 | Schema::create('users', function (Blueprint $table) { 323 | $table->id(); 324 | $table->jsonb('role_ids'); 325 | $table->index('role_ids')->algorithm('gin'); 326 | }); 327 | ``` 328 | 329 | Use this migration when the array is nested inside an object (e.g. `users.options->role_ids`): 330 | 331 | ```php 332 | Schema::create('users', function (Blueprint $table) { 333 | $table->id(); 334 | $table->jsonb('options'); 335 | $table->rawIndex('("options"->\'role_ids\')', 'users_options_index')->algorithm('gin'); 336 | }); 337 | ``` 338 | 339 | ### Has-Many-Through Relationships 340 | 341 | Similar to Laravel's [`HasManyThrough`](https://laravel.com/docs/9.x/eloquent-relationships#has-many-through), you can 342 | define `HasManyThroughJson` relationships when the JSON column is in the intermediate table (Laravel 9+). This 343 | requires [staudenmeir/eloquent-has-many-deep](https://github.com/staudenmeir/eloquent-has-many-deep). 344 | 345 | Consider a relationship between `Role` and `Project` through `User`: 346 | 347 | `Role` → has many JSON → `User` → has many `Project` 348 | 349 | [Install](https://github.com/staudenmeir/eloquent-has-many-deep/#installation) the additional package, add the 350 | `HasRelationships` trait to the parent (first) model and pass the JSON column as a `JsonKey` object: 351 | 352 | ```php 353 | class Role extends Model 354 | { 355 | use \Staudenmeir\EloquentHasManyDeep\HasRelationships; 356 | 357 | public function projects() 358 | { 359 | return $this->hasManyThroughJson( 360 | Project::class, 361 | User::class, 362 | new \Staudenmeir\EloquentJsonRelations\JsonKey('options->role_ids') 363 | ); 364 | } 365 | } 366 | ``` 367 | 368 | The reverse relationship would look like this: 369 | 370 | ```php 371 | class Project extends Model 372 | { 373 | use \Staudenmeir\EloquentHasManyDeep\HasRelationships; 374 | 375 | public function roles() 376 | { 377 | return $this->hasManyThroughJson( 378 | Role::class, User::class, 'id', 'id', 'user_id', new JsonKey('options->role_ids') 379 | ); 380 | } 381 | } 382 | ``` 383 | 384 | ### Deep Relationship Concatenation 385 | 386 | You can include JSON relationships into deep relationships by concatenating them with other relationships 387 | using [staudenmeir/eloquent-has-many-deep](https://github.com/staudenmeir/eloquent-has-many-deep) (Laravel 9+). 388 | 389 | Consider a relationship between `User` and `Permission` through `Role`: 390 | 391 | `User` → belongs to JSON → `Role` → has many → `Permission` 392 | 393 | [Install](https://github.com/staudenmeir/eloquent-has-many-deep/#installation) the additional package, add the 394 | `HasRelationships` trait to the parent (first) model 395 | and [define](https://github.com/staudenmeir/eloquent-has-many-deep/#concatenating-existing-relationships) a 396 | deep relationship: 397 | 398 | ```php 399 | class User extends Model 400 | { 401 | use \Staudenmeir\EloquentHasManyDeep\HasRelationships; 402 | use \Staudenmeir\EloquentJsonRelations\HasJsonRelationships; 403 | 404 | public function permissions(): \Staudenmeir\EloquentHasManyDeep\HasManyDeep 405 | { 406 | return $this->hasManyDeepFromRelations( 407 | $this->roles(), 408 | (new Role)->permissions() 409 | ); 410 | } 411 | 412 | public function roles(): \Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson 413 | { 414 | return $this->belongsToJson(Role::class, 'options->role_ids'); 415 | } 416 | } 417 | 418 | class Role extends Model 419 | { 420 | public function permissions() 421 | { 422 | return $this->hasMany(Permission::class); 423 | } 424 | } 425 | 426 | $permissions = User::find($id)->permissions; 427 | ``` 428 | 429 | ## Contributing 430 | 431 | Please see [CONTRIBUTING](.github/CONTRIBUTING.md) and [CODE OF CONDUCT](.github/CODE_OF_CONDUCT.md) for details. 432 | -------------------------------------------------------------------------------- /src/HasJsonRelationships.php: -------------------------------------------------------------------------------- 1 | |\[])/', $key)[0]; 36 | 37 | if (array_key_exists($attribute, $this->attributes)) { 38 | return $this->getAttributeValue($key); 39 | } 40 | 41 | return parent::getAttribute($key); 42 | } 43 | 44 | /** @inheritDoc */ 45 | public function getAttributeFromArray($key) 46 | { 47 | if (str_contains($key, '->')) { 48 | return $this->getAttributeValue($key); 49 | } 50 | 51 | return parent::getAttributeFromArray($key); 52 | } 53 | 54 | /** @inheritDoc */ 55 | public function getAttributeValue($key) 56 | { 57 | if (str_contains($key, '->')) { 58 | [$key, $path] = explode('->', $key, 2); 59 | 60 | if (substr($key, -2) === '[]') { 61 | $key = substr($key, 0, -2); 62 | 63 | $path = '*.'.$path; 64 | } 65 | 66 | $path = str_replace(['->', '[]'], ['.', '.*'], $path); 67 | 68 | return data_get($this->getAttributeValue($key), $path); 69 | } 70 | 71 | return parent::getAttributeValue($key); 72 | } 73 | 74 | /** @inheritDoc */ 75 | protected function newHasOne(Builder $query, Model $parent, $foreignKey, $localKey) 76 | { 77 | /** @var \Illuminate\Database\Connection $connection */ 78 | $connection = $query->getConnection(); 79 | 80 | if ($connection->getDriverName() === 'pgsql') { 81 | return new HasOnePostgres($query, $parent, $foreignKey, $localKey); 82 | } 83 | 84 | return new HasOne($query, $parent, $foreignKey, $localKey); 85 | } 86 | 87 | /** @inheritDoc */ 88 | protected function newHasOneThrough(Builder $query, Model $farParent, Model $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey) 89 | { 90 | /** @var \Illuminate\Database\Connection $connection */ 91 | $connection = $query->getConnection(); 92 | 93 | if ($connection->getDriverName() === 'pgsql') { 94 | return new HasOneThroughPostgres($query, $farParent, $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey); 95 | } 96 | 97 | return new HasOneThrough($query, $farParent, $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey); 98 | } 99 | 100 | /** @inheritDoc */ 101 | protected function newMorphOne(Builder $query, Model $parent, $type, $id, $localKey) 102 | { 103 | /** @var \Illuminate\Database\Connection $connection */ 104 | $connection = $query->getConnection(); 105 | 106 | if ($connection->getDriverName() === 'pgsql') { 107 | return new MorphOnePostgres($query, $parent, $type, $id, $localKey); 108 | } 109 | 110 | return new MorphOne($query, $parent, $type, $id, $localKey); 111 | } 112 | 113 | /** @inheritDoc */ 114 | protected function newBelongsTo(Builder $query, Model $child, $foreignKey, $ownerKey, $relation) 115 | { 116 | /** @var \Illuminate\Database\Connection $connection */ 117 | $connection = $query->getConnection(); 118 | 119 | if ($connection->getDriverName() === 'pgsql') { 120 | return new BelongsToPostgres($query, $child, $foreignKey, $ownerKey, $relation); 121 | } 122 | 123 | return new BelongsTo($query, $child, $foreignKey, $ownerKey, $relation); 124 | } 125 | 126 | /** @inheritDoc */ 127 | protected function newHasMany(Builder $query, Model $parent, $foreignKey, $localKey) 128 | { 129 | /** @var \Illuminate\Database\Connection $connection */ 130 | $connection = $query->getConnection(); 131 | 132 | if ($connection->getDriverName() === 'pgsql') { 133 | return new HasManyPostgres($query, $parent, $foreignKey, $localKey); 134 | } 135 | 136 | return new HasMany($query, $parent, $foreignKey, $localKey); 137 | } 138 | 139 | /** @inheritDoc */ 140 | protected function newHasManyThrough(Builder $query, Model $farParent, Model $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey) 141 | { 142 | /** @var \Illuminate\Database\Connection $connection */ 143 | $connection = $query->getConnection(); 144 | 145 | if ($connection->getDriverName() === 'pgsql') { 146 | return new HasManyThroughPostgres($query, $farParent, $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey); 147 | } 148 | 149 | return new HasManyThrough($query, $farParent, $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey); 150 | } 151 | 152 | /** @inheritDoc */ 153 | protected function newMorphMany(Builder $query, Model $parent, $type, $id, $localKey) 154 | { 155 | /** @var \Illuminate\Database\Connection $connection */ 156 | $connection = $query->getConnection(); 157 | 158 | if ($connection->getDriverName() === 'pgsql') { 159 | return new MorphManyPostgres($query, $parent, $type, $id, $localKey); 160 | } 161 | 162 | return new MorphMany($query, $parent, $type, $id, $localKey); 163 | } 164 | 165 | /** 166 | * Define an inverse one-to-one or many JSON relationship. 167 | * 168 | * @template TRelatedModel of \Illuminate\Database\Eloquent\Model 169 | * 170 | * @param class-string $related 171 | * @param string|array $foreignKey 172 | * @param string|array $ownerKey 173 | * @param string $relation 174 | * @return \Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson 175 | */ 176 | public function belongsToJson($related, $foreignKey, $ownerKey = null, $relation = null) 177 | { 178 | if (is_null($relation)) { 179 | $relation = $this->guessBelongsToRelation(); 180 | } 181 | 182 | /** @var \Illuminate\Database\Eloquent\Model $instance */ 183 | $instance = $this->newRelatedInstance($related); 184 | 185 | $ownerKey = $ownerKey ?: $instance->getKeyName(); 186 | 187 | return $this->newBelongsToJson( 188 | $instance->newQuery(), 189 | $this, 190 | $foreignKey, 191 | $ownerKey, 192 | $relation 193 | ); 194 | } 195 | 196 | /** 197 | * Instantiate a new BelongsToJson relationship. 198 | * 199 | * @template TRelatedModel of \Illuminate\Database\Eloquent\Model 200 | * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model 201 | * 202 | * @param \Illuminate\Database\Eloquent\Builder $query 203 | * @param TDeclaringModel $child 204 | * @param string|array $foreignKey 205 | * @param string|array $ownerKey 206 | * @param string $relation 207 | * @return \Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson 208 | */ 209 | protected function newBelongsToJson(Builder $query, Model $child, $foreignKey, $ownerKey, $relation) 210 | { 211 | return new BelongsToJson($query, $child, $foreignKey, $ownerKey, $relation); 212 | } 213 | 214 | /** 215 | * Define a one-to-many JSON relationship. 216 | * 217 | * @template TRelatedModel of \Illuminate\Database\Eloquent\Model 218 | * 219 | * @param class-string $related 220 | * @param string|array $foreignKey 221 | * @param string|array $localKey 222 | * @return \Staudenmeir\EloquentJsonRelations\Relations\HasManyJson 223 | */ 224 | public function hasManyJson($related, $foreignKey, $localKey = null) 225 | { 226 | /** @var \Illuminate\Database\Eloquent\Model $instance */ 227 | $instance = $this->newRelatedInstance($related); 228 | 229 | if (is_array($foreignKey)) { 230 | $foreignKey = array_map( 231 | fn (string $key) => "{$instance->getTable()}.$key", 232 | (array) $foreignKey 233 | ); 234 | } else { 235 | $foreignKey = "{$instance->getTable()}.$foreignKey"; 236 | } 237 | 238 | $localKey = $localKey ?: $this->getKeyName(); 239 | 240 | return $this->newHasManyJson( 241 | $instance->newQuery(), 242 | $this, 243 | $foreignKey, 244 | $localKey 245 | ); 246 | } 247 | 248 | /** 249 | * Instantiate a new HasManyJson relationship. 250 | * 251 | * @template TRelatedModel of \Illuminate\Database\Eloquent\Model 252 | * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model 253 | * 254 | * @param \Illuminate\Database\Eloquent\Builder $query 255 | * @param TDeclaringModel $parent 256 | * @param string|array $foreignKey 257 | * @param string|array $localKey 258 | * @return \Staudenmeir\EloquentJsonRelations\Relations\HasManyJson 259 | */ 260 | protected function newHasManyJson(Builder $query, Model $parent, $foreignKey, $localKey) 261 | { 262 | return new HasManyJson($query, $parent, $foreignKey, $localKey); 263 | } 264 | 265 | /** 266 | * Define a one-to-one JSON relationship. 267 | * 268 | * @template TRelatedModel of \Illuminate\Database\Eloquent\Model 269 | * 270 | * @param class-string $related 271 | * @param string|array $foreignKey 272 | * @param string|array|null $localKey 273 | * @return \Staudenmeir\EloquentJsonRelations\Relations\HasOneJson 274 | */ 275 | public function hasOneJson(string $related, string|array $foreignKey, string|array|null $localKey = null): HasOneJson 276 | { 277 | /** @var \Illuminate\Database\Eloquent\Model $instance */ 278 | $instance = $this->newRelatedInstance($related); 279 | 280 | if (is_array($foreignKey)) { 281 | $foreignKey = array_map( 282 | fn (string $key) => "{$instance->getTable()}.$key", 283 | $foreignKey 284 | ); 285 | } else { 286 | $foreignKey = "{$instance->getTable()}.$foreignKey"; 287 | } 288 | 289 | $localKey = $localKey ?: $this->getKeyName(); 290 | 291 | return $this->newHasOneJson( 292 | $instance->newQuery(), 293 | $this, 294 | $foreignKey, 295 | $localKey 296 | ); 297 | } 298 | 299 | /** 300 | * Instantiate a new HasOneJson relationship. 301 | * 302 | * @template TRelatedModel of \Illuminate\Database\Eloquent\Model 303 | * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model 304 | * 305 | * @param \Illuminate\Database\Eloquent\Builder $query 306 | * @param TDeclaringModel $parent 307 | * @param string|array $foreignKey 308 | * @param string|array $localKey 309 | * @return \Staudenmeir\EloquentJsonRelations\Relations\HasOneJson 310 | */ 311 | protected function newHasOneJson(Builder $query, Model $parent, string|array $foreignKey, string|array $localKey): HasOneJson 312 | { 313 | return new HasOneJson($query, $parent, $foreignKey, $localKey); 314 | } 315 | 316 | /** 317 | * Define has-many-through JSON relationship. 318 | * 319 | * @template TRelatedModel of \Illuminate\Database\Eloquent\Model 320 | * 321 | * @param class-string $related 322 | * @param string $through 323 | * @param string|\Staudenmeir\EloquentJsonRelations\JsonKey $firstKey 324 | * @param string|null $secondKey 325 | * @param string|null $localKey 326 | * @param string|\Staudenmeir\EloquentJsonRelations\JsonKey|null $secondLocalKey 327 | * @return \Staudenmeir\EloquentHasManyDeep\HasManyDeep 328 | */ 329 | public function hasManyThroughJson( 330 | string $related, 331 | string $through, 332 | string|JsonKey $firstKey, 333 | ?string $secondKey = null, 334 | ?string $localKey = null, 335 | string|JsonKey|null $secondLocalKey = null 336 | ) { 337 | $relationships = []; 338 | 339 | $through = new $through(); 340 | 341 | if ($firstKey instanceof JsonKey) { 342 | $relationships[] = $this->hasManyJson($through, $firstKey, $localKey); 343 | 344 | $relationships[] = $through->hasMany($related, $secondKey, $secondLocalKey); 345 | } else { 346 | if (!method_exists($through, 'belongsToJson')) { 347 | //@codeCoverageIgnoreStart 348 | $message = 'Please add the HasJsonRelationships trait to the ' . $through::class . ' model.'; 349 | 350 | throw new RuntimeException($message); 351 | // @codeCoverageIgnoreEnd 352 | } 353 | 354 | $relationships[] = $this->hasMany($through, $firstKey, $localKey); 355 | 356 | $relationships[] = $through->belongsToJson($related, $secondLocalKey, $secondKey); 357 | } 358 | 359 | $hasManyThroughJson = $this->newHasManyThroughJson($relationships); 360 | 361 | $jsonKey = $firstKey instanceof JsonKey ? $firstKey : $secondLocalKey; 362 | 363 | if (str_contains($jsonKey, '[]->')) { 364 | $this->addHasManyThroughJsonPivotRelationship($hasManyThroughJson, $relationships, $through); 365 | } 366 | 367 | return $hasManyThroughJson; 368 | } 369 | 370 | /** 371 | * Add the pivot relationship to the has-many-through JSON relationship. 372 | * 373 | * @param \Staudenmeir\EloquentHasManyDeep\HasManyDeep $hasManyThroughJson 374 | * @param non-empty-list<\Illuminate\Database\Eloquent\Relations\Relation> $relationships 375 | * @param \Illuminate\Database\Eloquent\Model $through 376 | * @return void 377 | */ 378 | protected function addHasManyThroughJsonPivotRelationship( 379 | $hasManyThroughJson, 380 | array $relationships, 381 | Model $through 382 | ): void { 383 | if ($relationships[0] instanceof HasManyJson) { 384 | /** @var \Staudenmeir\EloquentJsonRelations\Relations\HasManyJson $hasManyJson */ 385 | $hasManyJson = $relationships[0]; 386 | 387 | $postGetCallback = function (Collection $models) use ($hasManyJson) { 388 | if (isset($models[0]->laravel_through_key)) { 389 | $hasManyJson->hydratePivotRelation( 390 | $models, 391 | $this, 392 | fn (Model $model) => json_decode($model->laravel_through_key ?? '[]', true) 393 | ); 394 | } 395 | }; 396 | 397 | $localKey = $this->{$hasManyJson->getLocalKeyName()}; 398 | 399 | if (!is_null($localKey)) { 400 | $hasManyThroughJson->withPostGetCallbacks([$postGetCallback]); 401 | } 402 | 403 | $hasManyThroughJson->withCustomEagerMatchingCallback( 404 | function (array $models, Collection $results, string $relation) use ($hasManyJson) { 405 | foreach ($models as $model) { 406 | $hasManyJson->hydratePivotRelation( 407 | $model->$relation, 408 | $model, 409 | fn (Model $model) => json_decode($model->laravel_through_key ?? '[]', true) 410 | ); 411 | } 412 | 413 | return $models; 414 | } 415 | ); 416 | } else { 417 | /** @var \Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson $belongsToJson */ 418 | $belongsToJson = $relationships[1]; 419 | 420 | $path = $belongsToJson->getForeignKeyPath(); 421 | 422 | $postProcessor = function (Model $model, array $attributes) use ($belongsToJson, $path) { 423 | $records = json_decode($attributes[$path], true); 424 | 425 | return $belongsToJson->pivotAttributes($model, $model, $records); 426 | }; 427 | 428 | $hasManyThroughJson->withPivot( 429 | $through->getTable(), 430 | [$path], 431 | accessor: 'pivot', 432 | postProcessor: $postProcessor 433 | ); 434 | } 435 | } 436 | 437 | /** 438 | * Instantiate a new HasManyThroughJson relationship. 439 | * 440 | * @param non-empty-list<\Illuminate\Database\Eloquent\Relations\Relation> $relationships 441 | * @return \Staudenmeir\EloquentHasManyDeep\HasManyDeep 442 | */ 443 | protected function newHasManyThroughJson(array $relationships) 444 | { 445 | if (!method_exists($this, 'hasManyDeepFromRelations')) { 446 | //@codeCoverageIgnoreStart 447 | $message = 'Please install staudenmeir/eloquent-has-many-deep and add the HasRelationships trait to this model.'; 448 | 449 | throw new RuntimeException($message); 450 | // @codeCoverageIgnoreEnd 451 | } 452 | 453 | return $this->hasManyDeepFromRelations($relationships); 454 | } 455 | } 456 | --------------------------------------------------------------------------------