From d871de12ba0561407fdfb8e308fb0db7250baf68 Mon Sep 17 00:00:00 2001 From: Jonas Staudenmeir Date: Mon, 18 Mar 2024 17:53:04 +0100 Subject: [PATCH] Support composite keys --- README.md | 33 ++++ src/HasJsonRelationships.php | 27 ++- src/Relations/BelongsToJson.php | 89 +++++++-- src/Relations/HasManyJson.php | 84 +++++++- .../SupportsBelongsToJsonCompositeKeys.php | 163 +++++++++++++++ .../SupportsHasManyJsonCompositeKeys.php | 186 ++++++++++++++++++ tests/CompositeKeys/BelongsToJsonTest.php | 92 +++++++++ tests/CompositeKeys/HasManyJsonTest.php | 104 ++++++++++ tests/Models/Employee.php | 31 +++ tests/Models/Task.php | 26 +++ tests/TestCase.php | 55 ++++++ 11 files changed, 861 insertions(+), 29 deletions(-) create mode 100644 src/Relations/Traits/CompositeKeys/SupportsBelongsToJsonCompositeKeys.php create mode 100644 src/Relations/Traits/CompositeKeys/SupportsHasManyJsonCompositeKeys.php create mode 100644 tests/CompositeKeys/BelongsToJsonTest.php create mode 100644 tests/CompositeKeys/HasManyJsonTest.php create mode 100644 tests/Models/Employee.php create mode 100644 tests/Models/Task.php diff --git a/README.md b/README.md index 6cbb04b..f1927cc 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ Use this command if you are in PowerShell on Windows (e.g. in VS Code): - [Many-To-Many Relationships](#many-to-many-relationships) - [Array of IDs](#array-of-ids) - [Array of Objects](#array-of-objects) + - [Composite Keys](#composite-keys) - [Query Performance](#query-performance) - [Has-Many-Through Relationships](#has-many-through-relationships) - [Deep Relationship Concatenation](#deep-relationship-concatenation) @@ -225,6 +226,38 @@ $user->roles()->toggle([2 => ['active' => true], 3])->save(); **Limitations:** On SQLite and SQL Server, these relationships only work partially. +#### Composite Keys + +If multiple columns need to match, you can define a composite key. + +Pass an array of keys that starts with JSON key: + +```php +class Employee extends Model +{ + public function tasks() + { + return $this->belongsToJson( + Task::class, + ['options->work_stream_ids', 'team_id'], + ['work_stream_id', 'team_id'] + ); + } +} + +class Task extends Model +{ + public function employees() + { + return $this->hasManyJson( + Employee::class, + ['options->work_stream_ids', 'team_id'], + ['work_stream_id', 'team_id'] + ); + } +} +``` + #### Query Performance ##### MySQL diff --git a/src/HasJsonRelationships.php b/src/HasJsonRelationships.php index e3db8ec..acd2c85 100644 --- a/src/HasJsonRelationships.php +++ b/src/HasJsonRelationships.php @@ -221,8 +221,8 @@ protected function newMorphMany(Builder $query, Model $parent, $type, $id, $loca * Define an inverse one-to-one or many JSON relationship. * * @param string $related - * @param string $foreignKey - * @param string $ownerKey + * @param string|array $foreignKey + * @param string|array $ownerKey * @param string $relation * @return \Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson */ @@ -251,8 +251,8 @@ public function belongsToJson($related, $foreignKey, $ownerKey = null, $relation * * @param \Illuminate\Database\Eloquent\Builder $query * @param \Illuminate\Database\Eloquent\Model $child - * @param string $foreignKey - * @param string $ownerKey + * @param string|array $foreignKey + * @param string|array $ownerKey * @param string $relation * @return \Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson */ @@ -265,8 +265,8 @@ protected function newBelongsToJson(Builder $query, Model $child, $foreignKey, $ * Define a one-to-many JSON relationship. * * @param string $related - * @param string $foreignKey - * @param string $localKey + * @param string|array $foreignKey + * @param string|array $localKey * @return \Staudenmeir\EloquentJsonRelations\Relations\HasManyJson */ public function hasManyJson($related, $foreignKey, $localKey = null) @@ -274,12 +274,21 @@ public function hasManyJson($related, $foreignKey, $localKey = null) /** @var \Illuminate\Database\Eloquent\Model $instance */ $instance = $this->newRelatedInstance($related); + if (is_array($foreignKey)) { + $foreignKey = array_map( + fn (string $key) => "{$instance->getTable()}.$key", + (array) $foreignKey + ); + } else { + $foreignKey = "{$instance->getTable()}.$foreignKey"; + } + $localKey = $localKey ?: $this->getKeyName(); return $this->newHasManyJson( $instance->newQuery(), $this, - $instance->getTable().'.'.$foreignKey, + $foreignKey, $localKey ); } @@ -289,8 +298,8 @@ public function hasManyJson($related, $foreignKey, $localKey = null) * * @param \Illuminate\Database\Eloquent\Builder $query * @param \Illuminate\Database\Eloquent\Model $parent - * @param string $foreignKey - * @param string $localKey + * @param string|array $foreignKey + * @param string|array $localKey * @return \Staudenmeir\EloquentJsonRelations\Relations\HasManyJson */ protected function newHasManyJson(Builder $query, Model $parent, $foreignKey, $localKey) diff --git a/src/Relations/BelongsToJson.php b/src/Relations/BelongsToJson.php index 6661c55..4c72878 100644 --- a/src/Relations/BelongsToJson.php +++ b/src/Relations/BelongsToJson.php @@ -9,6 +9,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Collection as BaseCollection; use Staudenmeir\EloquentHasManyDeepContracts\Interfaces\ConcatenableRelation; +use Staudenmeir\EloquentJsonRelations\Relations\Traits\CompositeKeys\SupportsBelongsToJsonCompositeKeys; use Staudenmeir\EloquentJsonRelations\Relations\Traits\Concatenation\IsConcatenableBelongsToJsonRelation; use Staudenmeir\EloquentJsonRelations\Relations\Traits\IsJsonRelation; @@ -17,6 +18,21 @@ class BelongsToJson extends BelongsTo implements ConcatenableRelation use InteractsWithPivotRecords; use IsConcatenableBelongsToJsonRelation; use IsJsonRelation; + use SupportsBelongsToJsonCompositeKeys; + + /** + * The foreign key of the parent model. + * + * @var string|array + */ + protected $foreignKey; + + /** + * The associated key on the parent model. + * + * @var string|array + */ + protected $ownerKey; /** * Create a new belongs to JSON relationship instance. @@ -30,7 +46,9 @@ class BelongsToJson extends BelongsTo implements ConcatenableRelation */ public function __construct(Builder $query, Model $child, $foreignKey, $ownerKey, $relationName) { - $segments = explode('[]->', $foreignKey); + $segments = is_array($foreignKey) + ? explode('[]->', $foreignKey[0]) + : explode('[]->', $foreignKey); $this->path = $segments[0]; $this->key = $segments[1] ?? null; @@ -81,8 +99,31 @@ public function addConstraints() if (static::$constraints) { $table = $this->related->getTable(); - $this->query->whereIn($table.'.'.$this->ownerKey, $this->getForeignKeys()); + $ownerKey = $this->hasCompositeKey() ? $this->ownerKey[0] : $this->ownerKey; + + $this->query->whereIn("$table.$ownerKey", $this->getForeignKeys()); + + if ($this->hasCompositeKey()) { + $this->addConstraintsWithCompositeKey(); + } + } + } + + /** + * Set the constraints for an eager load of the relation. + * + * @param array $models + * @return void + */ + public function addEagerConstraints(array $models) + { + if ($this->hasCompositeKey()) { + $this->addEagerConstraintsWithCompositeKey($models); + + return; } + + parent::addEagerConstraints($models); } /** @@ -114,22 +155,30 @@ protected function getEagerModelKeys(array $models) */ public function match(array $models, Collection $results, $relation) { - $dictionary = $this->buildDictionary($results); + if ($this->hasCompositeKey()) { + $this->matchWithCompositeKey($models, $results, $relation); + } else { + $dictionary = $this->buildDictionary($results); - foreach ($models as $model) { - $matches = []; + foreach ($models as $model) { + $matches = []; - foreach ($this->getForeignKeys($model) as $id) { - if (isset($dictionary[$id])) { - $matches[] = $dictionary[$id]; + foreach ($this->getForeignKeys($model) as $id) { + if (isset($dictionary[$id])) { + $matches[] = $dictionary[$id]; + } } - } - $model->setRelation($relation, $collection = $this->related->newCollection($matches)); + $collection = $this->related->newCollection($matches); + $model->setRelation($relation, $collection); + } + } + + foreach ($models as $model) { if ($this->key) { $this->hydratePivotRelation( - $collection, + $model->getRelation($relation), $model, fn (Model $model, Model $parent) => $parent->{$this->path} ); @@ -170,7 +219,9 @@ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, return $this->getRelationExistenceQueryForSelfRelation($query, $parentQuery, $columns); } - [$sql, $bindings] = $this->relationExistenceQueryOwnerKey($query, $this->ownerKey); + $ownerKey = $this->hasCompositeKey() ? $this->ownerKey[0] : $this->ownerKey; + + [$sql, $bindings] = $this->relationExistenceQueryOwnerKey($query, $ownerKey); $query->addBinding($bindings); @@ -180,6 +231,10 @@ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $query->getQuery()->connection->raw($sql) ); + if ($this->hasCompositeKey()) { + $this->getRelationExistenceQueryWithCompositeKey($query); + } + return $query->select($columns); } @@ -257,9 +312,11 @@ public function pivotAttributes(Model $model, Model $parent, array $records) { $key = str_replace('->', '.', $this->key); + $ownerKey = $this->hasCompositeKey() ? $this->ownerKey[0] : $this->ownerKey; + $record = (new BaseCollection($records)) - ->filter(function ($value) use ($key, $model) { - return Arr::get($value, $key) == $model->{$this->ownerKey}; + ->filter(function ($value) use ($key, $model, $ownerKey) { + return Arr::get($value, $key) == $model->$ownerKey; })->first(); return Arr::except($record, $key); @@ -275,7 +332,9 @@ public function getForeignKeys(Model $model = null) { $model = $model ?: $this->child; - return (new BaseCollection($model->{$this->foreignKey}))->filter(fn ($key) => $key !== null)->all(); + $foreignKey = $this->hasCompositeKey() ? $this->foreignKey[0] : $this->foreignKey; + + return (new BaseCollection($model->$foreignKey))->filter(fn ($key) => $key !== null)->all(); } /** diff --git a/src/Relations/HasManyJson.php b/src/Relations/HasManyJson.php index 8cad489..1768b7c 100644 --- a/src/Relations/HasManyJson.php +++ b/src/Relations/HasManyJson.php @@ -10,12 +10,28 @@ use Illuminate\Support\Collection as BaseCollection; use Staudenmeir\EloquentHasManyDeepContracts\Interfaces\ConcatenableRelation; use Staudenmeir\EloquentJsonRelations\Relations\Traits\Concatenation\IsConcatenableHasManyJsonRelation; +use Staudenmeir\EloquentJsonRelations\Relations\Traits\CompositeKeys\SupportsHasManyJsonCompositeKeys; use Staudenmeir\EloquentJsonRelations\Relations\Traits\IsJsonRelation; class HasManyJson extends HasMany implements ConcatenableRelation { use IsConcatenableHasManyJsonRelation; use IsJsonRelation; + use SupportsHasManyJsonCompositeKeys; + + /** + * The foreign key of the parent model. + * + * @var string|array + */ + protected $foreignKey; + + /** + * The local key of the parent model. + * + * @var string|array + */ + protected $localKey; /** * Create a new has many JSON relationship instance. @@ -28,7 +44,9 @@ class HasManyJson extends HasMany implements ConcatenableRelation */ public function __construct(Builder $query, Model $parent, $foreignKey, $localKey) { - $segments = explode('[]->', $foreignKey); + $segments = is_array($foreignKey) + ? explode('[]->', $foreignKey[0]) + : explode('[]->', $foreignKey); $this->path = $segments[0]; $this->key = $segments[1] ?? null; @@ -58,7 +76,9 @@ public function get($columns = ['*']) { $models = parent::get($columns); - if ($this->key && !is_null($this->parent->{$this->localKey})) { + $localKey = $this->hasCompositeKey() ? $this->localKey[0] : $this->localKey; + + if ($this->key && !is_null($this->parent->$localKey)) { $this->hydratePivotRelation( $models, $this->parent, @@ -85,6 +105,10 @@ public function addConstraints() $parentKey, fn ($parentKey) => $this->parentKeyToArray($parentKey) ); + + if ($this->hasCompositeKey()) { + $this->addConstraintsWithCompositeKey(); + } } } @@ -96,6 +120,12 @@ public function addConstraints() */ public function addEagerConstraints(array $models) { + if ($this->hasCompositeKey()) { + $this->addEagerConstraintsWithCompositeKey($models); + + return; + } + $parentKeys = $this->getKeys($models, $this->localKey); $this->query->where(function (Builder $query) use ($parentKeys) { @@ -139,7 +169,11 @@ protected function parentKeyToArray($parentKey) */ protected function matchOneOrMany(array $models, Collection $results, $relation, $type) { - $models = parent::matchOneOrMany(...func_get_args()); + if ($this->hasCompositeKey()) { + $this->matchWithCompositeKey($models, $results, $relation); + } else { + parent::matchOneOrMany($models, $results, $relation, $type); + } if ($this->key) { foreach ($models as $model) { @@ -215,6 +249,10 @@ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $query->getQuery()->connection->raw($sql) ); + if ($this->hasCompositeKey()) { + $this->getRelationExistenceQueryWithCompositeKey($query); + } + return $query->select($columns); } @@ -291,9 +329,11 @@ public function pivotAttributes(Model $model, Model $parent, array $records) { $key = str_replace('->', '.', $this->key); + $localKey = $this->hasCompositeKey() ? $this->localKey[0] : $this->localKey; + $record = (new BaseCollection($records)) - ->filter(function ($value) use ($key, $parent) { - return Arr::get($value, $key) == $parent->{$this->localKey}; + ->filter(function ($value) use ($key, $localKey, $parent) { + return Arr::get($value, $key) == $parent->$localKey; })->first(); return Arr::except($record, $key); @@ -308,4 +348,38 @@ public function getPathName() { return last(explode('.', $this->path)); } + + /** + * Get the key value of the parent's local key. + * + * @return mixed + */ + public function getParentKey() + { + $localKey = $this->hasCompositeKey() ? $this->localKey[0] : $this->localKey; + + return $this->parent->getAttribute($localKey); + } + + /** + * Get the fully qualified parent key name. + * + * @return string + */ + public function getQualifiedParentKeyName() + { + $localKey = $this->hasCompositeKey() ? $this->localKey[0] : $this->localKey; + + return $this->parent->qualifyColumn($localKey); + } + + /** + * Get the foreign key for the relationship. + * + * @return string + */ + public function getQualifiedForeignKeyName() + { + return $this->hasCompositeKey() ? $this->foreignKey[0] : $this->foreignKey; + } } diff --git a/src/Relations/Traits/CompositeKeys/SupportsBelongsToJsonCompositeKeys.php b/src/Relations/Traits/CompositeKeys/SupportsBelongsToJsonCompositeKeys.php new file mode 100644 index 0000000..0da579f --- /dev/null +++ b/src/Relations/Traits/CompositeKeys/SupportsBelongsToJsonCompositeKeys.php @@ -0,0 +1,163 @@ +foreignKey); + } + + /** + * Set the base constraints on the relation query for a composite key. + * + * @return void + */ + protected function addConstraintsWithCompositeKey(): void + { + $columns = array_slice($this->ownerKey, 1); + + foreach ($columns as $column) { + $this->query->where( + $this->related->qualifyColumn($column), + '=', + $this->child->$column + ); + } + } + + /** + * Set the constraints for an eager load of the relation for a composite key. + * + * @param array $models + * @return void + */ + protected function addEagerConstraintsWithCompositeKey(array $models): void + { + $keys = (new BaseCollection($models))->map( + function (Model $model) { + return array_map( + fn (string $column) => $model[$column], + $this->foreignKey + ); + } + )->values()->unique(null, true)->all(); + + $this->query->where( + function (Builder $query) use ($keys) { + foreach ($keys as $key) { + $query->orWhere( + function (Builder $query) use ($key) { + foreach ($this->ownerKey as $i => $column) { + if ($i === 0) { + $query->whereIn( + $this->related->qualifyColumn($column), + $key[$i] + ); + } else { + $query->where( + $this->related->qualifyColumn($column), + '=', + $key[$i] + ); + } + } + } + ); + } + } + ); + } + + /** + * Match the eagerly loaded results to their parents for a composite key. + * + * @param array $models + * @param \Illuminate\Database\Eloquent\Collection $results + * @param string $relation + * @return array + */ + protected function matchWithCompositeKey(array $models, Collection $results, string $relation): array + { + $dictionary = $this->buildDictionaryWithCompositeKey($results); + + foreach ($models as $model) { + $matches = []; + + $additionalValues = array_map( + fn (string $key) => $model->$key, + array_slice($this->ownerKey, 1) + ); + + foreach ($this->getForeignKeys($model) as $id) { + $values = $additionalValues; + + array_unshift($values, $id); + + $key = implode("\0", $values); + + $matches = array_merge($matches, $dictionary[$key] ?? []); + } + + $collection = $this->related->newCollection($matches); + + $model->setRelation($relation, $collection); + } + + return $models; + } + + /** + * Build model dictionary keyed by the relation's composite foreign key. + * + * @param \Illuminate\Database\Eloquent\Collection $results + * @return array + */ + protected function buildDictionaryWithCompositeKey(Collection $results): array + { + $dictionary = []; + + foreach ($results as $result) { + $values = array_map( + fn (string $key) => $result->$key, + $this->ownerKey + ); + + $values = implode("\0", $values); + + $dictionary[$values][] = $result; + } + + return $dictionary; + } + + /** + * Add the constraints for a relationship query for a composite key. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @return void + */ + public function getRelationExistenceQueryWithCompositeKey(Builder $query): void + { + $columns = array_slice($this->foreignKey, 1, preserve_keys: true); + + foreach ($columns as $i => $column) { + $query->whereColumn( + $this->child->qualifyColumn($column), + '=', + $query->qualifyColumn($this->ownerKey[$i]) + ); + } + } +} diff --git a/src/Relations/Traits/CompositeKeys/SupportsHasManyJsonCompositeKeys.php b/src/Relations/Traits/CompositeKeys/SupportsHasManyJsonCompositeKeys.php new file mode 100644 index 0000000..9788ec3 --- /dev/null +++ b/src/Relations/Traits/CompositeKeys/SupportsHasManyJsonCompositeKeys.php @@ -0,0 +1,186 @@ +foreignKey); + } + + /** + * Set the base constraints on the relation query for a composite key. + * + * @return void + */ + protected function addConstraintsWithCompositeKey(): void + { + $columns = array_slice($this->localKey, 1); + + foreach ($columns as $column) { + $this->query->where( + $this->related->qualifyColumn($column), + '=', + $this->parent->$column + ); + } + } + + /** + * Set the constraints for an eager load of the relation for a composite key. + * + * @param array $models + * @return void + */ + protected function addEagerConstraintsWithCompositeKey(array $models): void + { + $keys = (new BaseCollection($models))->map( + function (Model $model) { + return array_map( + fn (string $column) => $model[$column], + $this->localKey + ); + } + )->values()->unique(null, true)->all(); + + $this->query->where( + function (Builder $query) use ($keys) { + foreach ($keys as $key) { + $query->orWhere( + function (Builder $query) use ($key) { + foreach ($this->foreignKey as $i => $column) { + if ($i === 0) { + $this->whereJsonContainsOrMemberOf( + $query, + $this->path, + $key[$i], + fn ($parentKey) => $this->parentKeyToArray($parentKey) + ); + } else { + $query->where( + $this->related->qualifyColumn($column), + '=', + $key[$i] + ); + } + } + } + ); + } + } + ); + } + + /** + * Match the eagerly loaded results to their parents for a composite key. + * + * @param array $models + * @param \Illuminate\Database\Eloquent\Collection $results + * @param string $relation + * @return array + */ + protected function matchWithCompositeKey(array $models, Collection $results, string $relation): array + { + $dictionary = $this->buildDictionaryWithCompositeKey($results); + + foreach ($models as $model) { + $values = array_map( + fn ($key) => $model->$key, + $this->localKey + ); + + $key = implode("\0", $values); + + if (isset($dictionary[$key])) { + $model->setRelation( + $relation, + $this->getRelationValue($dictionary, $key, 'many') + ); + } + } + + return $models; + } + + /** + * Build model dictionary keyed by the relation's composite foreign key. + * + * @param \Illuminate\Database\Eloquent\Collection $results + * @return array + */ + protected function buildDictionaryWithCompositeKey(Collection $results): array + { + $dictionary = []; + + $foreignKey = $this->getForeignKeyName(); + + $additionalColumns = $this->getAdditionalForeignKeyNames(); + + foreach ($results as $result) { + $additionalValues = array_map( + fn (string $column) => $result->getAttribute($column), + $additionalColumns + ); + + foreach($result->$foreignKey as $value) { + $values = [$value, ...$additionalValues]; + + $key = implode("\0", $values); + + $dictionary[$key][] = $result; + } + } + + return $dictionary; + } + + /** + * Add the constraints for a relationship query for a composite key. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @return void + */ + public function getRelationExistenceQueryWithCompositeKey(Builder $query): void + { + $columns = $this->getAdditionalForeignKeyNames(); + + foreach ($columns as $i => $column) { + $query->whereColumn( + $this->parent->qualifyColumn($column), + '=', + $query->qualifyColumn($this->localKey[$i]) + ); + } + } + + /** + * Get the plain additional foreign keys. + * + * @return array + */ + protected function getAdditionalForeignKeyNames(): array + { + $names = []; + + $columns = array_slice($this->foreignKey, 1, preserve_keys: true); + + foreach ($columns as $i => $column) { + $segments = explode('.', $column); + + $names[$i] = end($segments); + } + + return $names; + } +} diff --git a/tests/CompositeKeys/BelongsToJsonTest.php b/tests/CompositeKeys/BelongsToJsonTest.php new file mode 100644 index 0000000..73a2203 --- /dev/null +++ b/tests/CompositeKeys/BelongsToJsonTest.php @@ -0,0 +1,92 @@ +tasks; + + $this->assertEquals([101, 103, 105], $tasks->pluck('id')->all()); + } + + public function testLazyLoadingWithObjects() + { + $tasks = Employee::find(121)->tasksWithObjects; + + $this->assertEquals([101, 103, 105], $tasks->pluck('id')->all()); + $this->assertEquals(['work_stream' => ['active' => true]], $tasks[0]->pivot->getAttributes()); + } + + public function testEmptyLazyLoading() + { + DB::enableQueryLog(); + + $tasks = (new Employee())->tasks; + + $this->assertInstanceOf(Collection::class, $tasks); + $this->assertEmpty(DB::getQueryLog()); + } + + public function testEagerLoading() + { + $employees = Employee::with('tasks')->get(); + + $this->assertEquals([101, 103, 105], $employees[0]->tasks->pluck('id')->all()); + $this->assertEquals([102, 104], $employees[1]->tasks->pluck('id')->all()); + $this->assertEquals([], $employees[3]->tasks->pluck('id')->all()); + } + + public function testEagerLoadingWithObjects() + { + $employees = Employee::with('tasksWithObjects')->get(); + + $this->assertEquals([101, 103, 105], $employees[0]->tasksWithObjects->pluck('id')->all()); + $this->assertEquals([102, 104], $employees[1]->tasksWithObjects->pluck('id')->all()); + $this->assertEquals([], $employees[3]->tasksWithObjects->pluck('id')->all()); + $this->assertEquals(['work_stream' => ['active' => true]], $employees[0]->tasksWithObjects[0]->pivot->getAttributes()); + } + + public function testLazyEagerLoading() + { + $employees = Employee::all()->load('tasks'); + + $this->assertEquals([101, 103, 105], $employees[0]->tasks->pluck('id')->all()); + $this->assertEquals([102, 104], $employees[1]->tasks->pluck('id')->all()); + $this->assertEquals([], $employees[3]->tasks->pluck('id')->all()); + } + + public function testLazyEagerLoadingWithObjects() + { + $employees = Employee::all()->load('tasksWithObjects'); + + $this->assertEquals([101, 103, 105], $employees[0]->tasksWithObjects->pluck('id')->all()); + $this->assertEquals([102, 104], $employees[1]->tasksWithObjects->pluck('id')->all()); + $this->assertEquals([], $employees[3]->tasksWithObjects->pluck('id')->all()); + $this->assertEquals(['work_stream' => ['active' => true]], $employees[0]->tasksWithObjects[0]->pivot->getAttributes()); + } + + public function testExistenceQuery() + { + $employees = Employee::has('tasks')->orderBy('id')->get(); + + $this->assertEquals([121, 122, 123], $employees->pluck('id')->all()); + } + + public function testExistenceQueryWithObjects() + { + if (in_array($this->connection, ['sqlite', 'sqlsrv'])) { + $this->markTestSkipped(); + } + + $employees = Employee::has('tasksWithObjects')->orderBy('id')->get(); + + $this->assertEquals([121, 122, 123], $employees->pluck('id')->all()); + } +} diff --git a/tests/CompositeKeys/HasManyJsonTest.php b/tests/CompositeKeys/HasManyJsonTest.php new file mode 100644 index 0000000..f22f1a9 --- /dev/null +++ b/tests/CompositeKeys/HasManyJsonTest.php @@ -0,0 +1,104 @@ +employees; + + $this->assertEquals([121, 123], $employees->pluck('id')->all()); + } + + public function testLazyLoadingWithObjects() + { + if (in_array($this->connection, ['sqlite', 'sqlsrv'])) { + $this->markTestSkipped(); + } + + $employees = Task::find(101)->employeesWithObjects; + + $this->assertEquals([121, 123], $employees->pluck('id')->all()); + $this->assertEquals(['work_stream' => ['active' => true]], $employees[0]->pivot->getAttributes()); + } + + public function testEmptyLazyLoading() + { + DB::enableQueryLog(); + + $employees = (new Task())->employees; + + $this->assertInstanceOf(Collection::class, $employees); + $this->assertEmpty(DB::getQueryLog()); + } + + public function testEagerLoading() + { + $tasks = Task::with('employees')->get(); + + $this->assertEquals([121, 123], $tasks[0]->employees->pluck('id')->all()); + $this->assertEquals([122], $tasks[1]->employees->pluck('id')->all()); + $this->assertEquals([], $tasks[5]->employees->pluck('id')->all()); + } + + public function testEagerLoadingWithObjects() + { + if (in_array($this->connection, ['sqlite', 'sqlsrv'])) { + $this->markTestSkipped(); + } + + $tasks = Task::with('employeesWithObjects')->get(); + + $this->assertEquals([121, 123], $tasks[0]->employeesWithObjects->pluck('id')->all()); + $this->assertEquals([122], $tasks[1]->employeesWithObjects->pluck('id')->all()); + $this->assertEquals([], $tasks[5]->employeesWithObjects->pluck('id')->all()); + $this->assertEquals(['work_stream' => ['active' => true]], $tasks[0]->employeesWithObjects[0]->pivot->getAttributes()); + } + + public function testLazyEagerLoading() + { + $tasks = Task::all()->load('employees'); + + $this->assertEquals([121, 123], $tasks[0]->employees->pluck('id')->all()); + $this->assertEquals([122], $tasks[1]->employees->pluck('id')->all()); + $this->assertEquals([], $tasks[5]->employees->pluck('id')->all()); + } + + public function testLazyEagerLoadingWithObjects() + { + if (in_array($this->connection, ['sqlite', 'sqlsrv'])) { + $this->markTestSkipped(); + } + + $tasks = Task::all()->load('employeesWithObjects'); + + $this->assertEquals([121, 123], $tasks[0]->employeesWithObjects->pluck('id')->all()); + $this->assertEquals([122], $tasks[1]->employeesWithObjects->pluck('id')->all()); + $this->assertEquals([], $tasks[5]->employeesWithObjects->pluck('id')->all()); + $this->assertEquals(['work_stream' => ['active' => true]], $tasks[0]->employeesWithObjects[0]->pivot->getAttributes()); + } + + public function testExistenceQuery() + { + $tasks = Task::has('employees')->orderBy('id')->get(); + + $this->assertEquals([101, 102, 103, 104, 105], $tasks->pluck('id')->all()); + } + + public function testExistenceQueryWithObjects() + { + if (in_array($this->connection, ['sqlite', 'sqlsrv'])) { + $this->markTestSkipped(); + } + + $tasks = Task::has('employeesWithObjects')->orderBy('id')->get(); + + $this->assertEquals([101, 102, 103, 104, 105], $tasks->pluck('id')->all()); + } +} diff --git a/tests/Models/Employee.php b/tests/Models/Employee.php new file mode 100644 index 0000000..f004622 --- /dev/null +++ b/tests/Models/Employee.php @@ -0,0 +1,31 @@ + 'json', + 'options' => 'json', + ]; + + public function tasks(): BelongsToJson + { + return $this->belongsToJson( + Task::class, + ['work_stream_ids', 'team_id'], + ['work_stream_id', 'team_id'] + ); + } + + public function tasksWithObjects(): BelongsToJson + { + return $this->belongsToJson( + Task::class, + ['options->work_streams[]->work_stream->id', 'team_id'], + ['work_stream_id', 'team_id'] + ); + } +} diff --git a/tests/Models/Task.php b/tests/Models/Task.php new file mode 100644 index 0000000..1f9156c --- /dev/null +++ b/tests/Models/Task.php @@ -0,0 +1,26 @@ +hasManyJson( + Employee::class, + ['work_stream_ids', 'team_id'], + ['work_stream_id', 'team_id'] + ); + } + + public function employeesWithObjects(): HasManyJson + { + return $this->hasManyJson( + Employee::class, + ['options->work_streams[]->work_stream->id', 'team_id'], + ['work_stream_id', 'team_id'] + ); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 460ebd6..1c30ab0 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -12,11 +12,13 @@ use Tests\Models\Category; use Tests\Models\Comment; use Tests\Models\Country; +use Tests\Models\Employee; use Tests\Models\Locale; use Tests\Models\Permission; use Tests\Models\Post; use Tests\Models\Product; use Tests\Models\Role; +use Tests\Models\Task; use Tests\Models\Team; use Tests\Models\Project; use Tests\Models\User; @@ -114,6 +116,19 @@ protected function migrate(): void $table->unsignedInteger('id'); $table->unsignedInteger('user_id'); }); + + DB::schema()->create('tasks', function (Blueprint $table) { + $table->unsignedInteger('id'); + $table->unsignedInteger('team_id'); + $table->unsignedInteger('work_stream_id'); + }); + + DB::schema()->create('employees', function (Blueprint $table) { + $table->unsignedInteger('id'); + $table->unsignedInteger('team_id'); + $table->json('work_stream_ids'); + $table->json('options'); + }); } protected function seed(): void @@ -225,6 +240,46 @@ protected function seed(): void 'user_id' => 23, ]); + Task::create(['id' => 101, 'team_id' => 1, 'work_stream_id' => 111]); + Task::create(['id' => 102, 'team_id' => 2, 'work_stream_id' => 111]); + Task::create(['id' => 103, 'team_id' => 1, 'work_stream_id' => 112]); + Task::create(['id' => 104, 'team_id' => 2, 'work_stream_id' => 112]); + Task::create(['id' => 105, 'team_id' => 1, 'work_stream_id' => 113]); + Task::create(['id' => 106, 'team_id' => 2, 'work_stream_id' => 113]); + + Employee::create(['id' => 121, 'team_id' => 1, 'work_stream_ids' => [111, 112, 113], + 'options' => [ + 'work_streams' => [ + ['work_stream' => ['id' => 111, 'active' => true]], + ['work_stream' => ['id' => 112, 'active' => false]], + ['work_stream' => ['id' => 113, 'active' => true]], + ], + ], + ]); + Employee::create(['id' => 122, 'team_id' => 2, 'work_stream_ids' => [111, 112], + 'options' => [ + 'work_streams' => [ + ['work_stream' => ['id' => 111, 'active' => false]], + ['work_stream' => ['id' => 112, 'active' => true]], + ], + ], + ]); + Employee::create(['id' => 123, 'team_id' => 1, 'work_stream_ids' => [111], + 'options' => [ + 'work_streams' => [ + ['work_stream' => ['id' => 111, 'active' => true]], + ], + ], + ]); + Employee::create(['id' => 124, 'team_id' => 3, 'work_stream_ids' => [111, 112], + 'options' => [ + 'work_streams' => [ + ['work_stream' => ['id' => 111, 'active' => true]], + ['work_stream' => ['id' => 112, 'active' => false]], + ], + ], + ]); + Model::reguard(); } }