diff --git a/UPGRADE.md b/UPGRADE.md index 931a0fdd78..35aeb18ce8 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -9,6 +9,13 @@ Compare your `lighthouse.php` against the latest [default configuration](src/lig ## v6 to v7 +### Nested mutations only act on related models + +The config option `nested_mutations_scope_to_parent` was removed. +Nested mutations now always scope to the parent. +The nested operations `delete`, `disconnect`, `update` and `upsert` throw an error when given the ID of a model that is not related to the parent. +To move a model to another parent, use `connect`. + ### Leverage automatic test trait setup Methods you need to explicitly call to set up test traits were removed in favor of automatic setup. diff --git a/docs/6/eloquent/nested-mutations.md b/docs/6/eloquent/nested-mutations.md index c3beaa8455..ac99beaa42 100644 --- a/docs/6/eloquent/nested-mutations.md +++ b/docs/6/eloquent/nested-mutations.md @@ -48,6 +48,17 @@ Field directives such as the [@can\* family of directives](../api-reference/dire Make sure that fields with nested mutations are only available to users who are allowed to execute all reachable nested mutations. +By default, the nested operations `delete`, `disconnect`, `update` and `upsert` accept IDs of models that are not related to the parent. +For example, `updateUser(input: { id: 2, tasks: { delete: [1] } })` deletes task 1 even if it belongs to user 1. +Set `nested_mutations_scope_to_parent` to `true` in `config/lighthouse.php` to limit them to related models: + +- An ID that is not related to the parent causes an error, even if no model with that ID exists. +- `upsert` creates a new model if no model with the given ID exists. +- `upsert` with `identifyingColumns` causes an error if the matching model is not related to the parent. + +`connect`, `sync`, `syncWithoutDetaching` and the `update` and `upsert` operations on [BelongsTo](#belongsto) relations are not limited, because their purpose is to establish new relationships. +Validate or authorize the IDs passed to them yourself. + ## BelongsTo We will start of by defining a mutation to create a post. diff --git a/docs/master/eloquent/nested-mutations.md b/docs/master/eloquent/nested-mutations.md index c3beaa8455..ac99beaa42 100644 --- a/docs/master/eloquent/nested-mutations.md +++ b/docs/master/eloquent/nested-mutations.md @@ -48,6 +48,17 @@ Field directives such as the [@can\* family of directives](../api-reference/dire Make sure that fields with nested mutations are only available to users who are allowed to execute all reachable nested mutations. +By default, the nested operations `delete`, `disconnect`, `update` and `upsert` accept IDs of models that are not related to the parent. +For example, `updateUser(input: { id: 2, tasks: { delete: [1] } })` deletes task 1 even if it belongs to user 1. +Set `nested_mutations_scope_to_parent` to `true` in `config/lighthouse.php` to limit them to related models: + +- An ID that is not related to the parent causes an error, even if no model with that ID exists. +- `upsert` creates a new model if no model with the given ID exists. +- `upsert` with `identifyingColumns` causes an error if the matching model is not related to the parent. + +`connect`, `sync`, `syncWithoutDetaching` and the `update` and `upsert` operations on [BelongsTo](#belongsto) relations are not limited, because their purpose is to establish new relationships. +Validate or authorize the IDs passed to them yourself. + ## BelongsTo We will start of by defining a mutation to create a post. diff --git a/src/Execution/Arguments/NestedManyToMany.php b/src/Execution/Arguments/NestedManyToMany.php index 8978997ac8..4b489805f3 100644 --- a/src/Execution/Arguments/NestedManyToMany.php +++ b/src/Execution/Arguments/NestedManyToMany.php @@ -42,7 +42,7 @@ public function __invoke($model, $args): void } if ($args->has('update')) { - $updateModel = new ResolveNested(new UpdateModel(new SaveModel($relation))); + $updateModel = new ResolveNested(new UpdateModel(new SaveModel($relation), $relation)); foreach ($args->arguments['update']->value as $childArgs) { $updateModel($relation->make(), $childArgs); @@ -50,7 +50,7 @@ public function __invoke($model, $args): void } if ($args->has('upsert')) { - $upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation))); + $upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation), parentRelation: $relation)); foreach ($args->arguments['upsert']->value as $childArgs) { $upsertModel($relation->make(), $childArgs); @@ -58,10 +58,7 @@ public function __invoke($model, $args): void } if ($args->has('delete')) { - $ids = $args->arguments['delete']->toPlain(); - - $relation->detach($ids); - $relation->getRelated()::destroy($ids); + ParentRelationScope::delete($relation, Arr::wrap($args->arguments['delete']->toPlain())); } if ($args->has('connect')) { diff --git a/src/Execution/Arguments/NestedOneToMany.php b/src/Execution/Arguments/NestedOneToMany.php index a1a35473ce..d2e68e3a69 100644 --- a/src/Execution/Arguments/NestedOneToMany.php +++ b/src/Execution/Arguments/NestedOneToMany.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphMany; +use Illuminate\Support\Arr; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; class NestedOneToMany implements ArgResolver @@ -30,7 +31,7 @@ public function __invoke($model, $args): void } if ($args->has('update')) { - $updateModel = new ResolveNested(new UpdateModel(new SaveModel($relation))); + $updateModel = new ResolveNested(new UpdateModel(new SaveModel($relation), $relation)); foreach ($args->arguments['update']->value as $childArgs) { $updateModel($relation->make(), $childArgs); @@ -38,7 +39,7 @@ public function __invoke($model, $args): void } if ($args->has('upsert')) { - $upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation))); + $upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation), parentRelation: $relation)); foreach ($args->arguments['upsert']->value as $childArgs) { $upsertModel($relation->make(), $childArgs); @@ -57,13 +58,7 @@ public function __invoke($model, $args): void } if ($args->has('disconnect')) { - $children = $relation - ->make() - ->whereIn( - $relation->make()->getKeyName(), - $args->arguments['disconnect']->value, - ) - ->get(); + $children = ParentRelationScope::findMany($relation, $args->arguments['disconnect']->value); foreach ($children as $child) { $child->setAttribute($relation->getForeignKeyName(), null); $child->save(); @@ -71,9 +66,7 @@ public function __invoke($model, $args): void } if ($args->has('delete')) { - $relation->getRelated()::destroy( - $args->arguments['delete']->toPlain(), - ); + ParentRelationScope::delete($relation, Arr::wrap($args->arguments['delete']->toPlain())); } } } diff --git a/src/Execution/Arguments/NestedOneToOne.php b/src/Execution/Arguments/NestedOneToOne.php index e08d551c5a..dee7e36cf1 100644 --- a/src/Execution/Arguments/NestedOneToOne.php +++ b/src/Execution/Arguments/NestedOneToOne.php @@ -5,6 +5,7 @@ use GraphQL\Error\Error; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphOne; +use Illuminate\Support\Arr; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; class NestedOneToOne implements ArgResolver @@ -36,21 +37,19 @@ public function __invoke($model, $args): void } if ($args->has('update')) { - $updateModel = new ResolveNested(new UpdateModel(new SaveModel($relation))); + $updateModel = new ResolveNested(new UpdateModel(new SaveModel($relation), $relation)); $updateModel($relation->make(), $args->arguments['update']->value); } if ($args->has('upsert')) { - $upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation))); + $upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation), parentRelation: $relation)); $upsertModel($relation->first() ?? $relation->make(), $args->arguments['upsert']->value); } if ($args->has('delete')) { - $relation->getRelated()::destroy( - $args->arguments['delete']->toPlain(), - ); + ParentRelationScope::delete($relation, Arr::wrap($args->arguments['delete']->toPlain())); } } } diff --git a/src/Execution/Arguments/ParentRelationScope.php b/src/Execution/Arguments/ParentRelationScope.php new file mode 100644 index 0000000000..04900e3d39 --- /dev/null +++ b/src/Execution/Arguments/ParentRelationScope.php @@ -0,0 +1,133 @@ + $values */ + public static function unrelatedModel(array $values): string + { + $valueList = implode(', ', array_map( + static fn (mixed $value): mixed => is_scalar($value) ? $value : json_encode($value), + $values, + )); + + return self::UNRELATED_MODEL . ": {$valueList}."; + } + + /** + * @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null $relation + * + * @phpstan-assert-if-true !null $relation + */ + public static function applies(?Relation $relation): bool + { + // TODO remove the config check with v7 + return $relation !== null + // BelongsTo update and upsert attach unrelated models by design + && ! $relation instanceof BelongsTo + && config('lighthouse.nested_mutations_scope_to_parent'); + } + + /** + * @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null $relation + * @param int|string $id + */ + public static function findOrFail(?Relation $relation, Model $model, mixed $id): Model + { + if (! self::applies($relation)) { + return $model->newQuery()->findOrFail($id); + } + + return self::findMany($relation, [$id])->firstOrFail(); + } + + /** + * @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model> $relation + * @param array $ids + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public static function findMany(Relation $relation, array $ids): EloquentCollection + { + if (! self::applies($relation)) { + $related = $relation->getRelated(); + + return $related->whereIn($related->getKeyName(), $ids)->get(); + } + + $models = (clone $relation)->whereKey($ids)->get(); + + $unrelatedIDs = array_diff($ids, $models->modelKeys()); + if ($unrelatedIDs !== []) { + throw new Error(self::unrelatedModel($unrelatedIDs)); + } + + return $models; + } + + /** + * @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model> $relation + * @param array $ids + */ + public static function delete(Relation $relation, array $ids): void + { + $models = self::findMany($relation, $ids); + + if ($relation instanceof BelongsToMany) { + $relation->detach($ids); + } + + foreach ($models as $model) { + $model->delete(); + } + } + + /** + * @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null $relation + * @param int|string $id + */ + public static function findForUpsert(?Relation $relation, Model $model, mixed $id): ?Model + { + if (! self::applies($relation)) { + return $model->newQuery()->find($id); + } + + return self::firstForUpsert($relation, $model, [$model->getKeyName() => $id]); + } + + /** + * @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null $relation + * @param array $columns + */ + public static function firstForUpsert(?Relation $relation, Model $model, array $columns): ?Model + { + if (! self::applies($relation)) { + return $model->newQuery()->firstWhere($columns); + } + + $qualifiedColumns = []; + foreach ($columns as $column => $value) { + $qualifiedColumns[$model->qualifyColumn($column)] = $value; + } + + $scopedModel = (clone $relation)->where($qualifiedColumns)->first(); + + if ($scopedModel === null && $model->newQuery()->where($columns)->exists()) { + throw new Error(self::unrelatedModel($columns)); + } + + return $scopedModel; + } +} diff --git a/src/Execution/Arguments/UpdateModel.php b/src/Execution/Arguments/UpdateModel.php index ea7fe955fc..912a8302c2 100644 --- a/src/Execution/Arguments/UpdateModel.php +++ b/src/Execution/Arguments/UpdateModel.php @@ -3,6 +3,7 @@ namespace Nuwave\Lighthouse\Execution\Arguments; use GraphQL\Error\Error; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Arr; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; use Nuwave\Lighthouse\Support\Contracts\PreSaveArgumentsAware; @@ -17,8 +18,11 @@ class UpdateModel implements ArgResolver, PreSaveArgumentsAware protected $previous; /** @param callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver $previous */ - public function __construct(callable $previous) - { + public function __construct( + callable $previous, + /** @var \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null */ + protected ?Relation $parentRelation = null, + ) { $this->previous = $previous; } @@ -33,7 +37,7 @@ public function __invoke($model, $args): mixed ?? throw new Error(self::MISSING_PRIMARY_KEY_FOR_UPDATE); assert($id instanceof Argument); - $model = $model->newQuery()->findOrFail($id->value); + $model = ParentRelationScope::findOrFail($this->parentRelation, $model, $id->value); return ($this->previous)($model, $args); } diff --git a/src/Execution/Arguments/UpsertModel.php b/src/Execution/Arguments/UpsertModel.php index 9725812585..501d1c5a3e 100644 --- a/src/Execution/Arguments/UpsertModel.php +++ b/src/Execution/Arguments/UpsertModel.php @@ -4,6 +4,7 @@ use GraphQL\Error\Error; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\Relation; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; use Nuwave\Lighthouse\Support\Contracts\PreSaveArgumentsAware; @@ -23,6 +24,8 @@ public function __construct( callable $previous, /** @var array|null */ protected ?array $identifyingColumns = null, + /** @var \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null */ + protected ?Relation $parentRelation = null, ) { $this->previous = $previous; } @@ -40,7 +43,7 @@ public function __invoke($model, $args): mixed $identifyingColumns = $this->identifyingColumnValues($args, $this->identifyingColumns) ?? throw new Error(self::MISSING_IDENTIFYING_COLUMNS_FOR_UPSERT); - $existingModel = $model->newQuery()->firstWhere($identifyingColumns); + $existingModel = ParentRelationScope::firstForUpsert($this->parentRelation, $model, $identifyingColumns); if ($existingModel !== null) { $model = $existingModel; @@ -50,7 +53,7 @@ public function __invoke($model, $args): mixed if ($existingModel === null) { $id = $this->retrieveID($model, $args); if ($id) { - $existingModel = $model->newQuery()->find($id); + $existingModel = ParentRelationScope::findForUpsert($this->parentRelation, $model, $id); if ($existingModel !== null) { $model = $existingModel; } diff --git a/src/Schema/Directives/DeleteDirective.php b/src/Schema/Directives/DeleteDirective.php index 9765ad464d..6541d9e27a 100644 --- a/src/Schema/Directives/DeleteDirective.php +++ b/src/Schema/Directives/DeleteDirective.php @@ -12,7 +12,9 @@ use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Support\Arr; use Nuwave\Lighthouse\Exceptions\DefinitionException; +use Nuwave\Lighthouse\Execution\Arguments\ParentRelationScope; use Nuwave\Lighthouse\Schema\AST\DocumentAST; use Nuwave\Lighthouse\Support\Contracts\ArgManipulator; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; @@ -93,8 +95,9 @@ public function __invoke($model, $idOrIds): void $relation->delete(); } } else { - $related = $relation->make(); - $related::destroy($idOrIds); + foreach (ParentRelationScope::findMany($relation, Arr::wrap($idOrIds)) as $related) { + $related->delete(); + } } } diff --git a/src/Schema/Directives/UpdateDirective.php b/src/Schema/Directives/UpdateDirective.php index 2f30f6af84..bc86f54343 100644 --- a/src/Schema/Directives/UpdateDirective.php +++ b/src/Schema/Directives/UpdateDirective.php @@ -33,6 +33,6 @@ public static function definition(): string protected function makeExecutionFunction(?Relation $parentRelation = null): callable { - return new UpdateModel(new SaveModel($parentRelation)); + return new UpdateModel(new SaveModel($parentRelation), $parentRelation); } } diff --git a/src/Schema/Directives/UpdateManyDirective.php b/src/Schema/Directives/UpdateManyDirective.php index 3af93be4bc..ab05c13f6a 100644 --- a/src/Schema/Directives/UpdateManyDirective.php +++ b/src/Schema/Directives/UpdateManyDirective.php @@ -33,6 +33,6 @@ public static function definition(): string protected function makeExecutionFunction(?Relation $parentRelation = null): callable { - return new UpdateModel(new SaveModel($parentRelation)); + return new UpdateModel(new SaveModel($parentRelation), $parentRelation); } } diff --git a/src/Schema/Directives/UpsertDirective.php b/src/Schema/Directives/UpsertDirective.php index 526ee860bc..02faf1d81e 100644 --- a/src/Schema/Directives/UpsertDirective.php +++ b/src/Schema/Directives/UpsertDirective.php @@ -52,6 +52,7 @@ protected function makeExecutionFunction(?Relation $parentRelation = null): call return new UpsertModel( new SaveModel($parentRelation), $this->directiveArgValue('identifyingColumns'), + $parentRelation, ); } diff --git a/src/Schema/Directives/UpsertManyDirective.php b/src/Schema/Directives/UpsertManyDirective.php index bc013293ce..77730e6aa1 100644 --- a/src/Schema/Directives/UpsertManyDirective.php +++ b/src/Schema/Directives/UpsertManyDirective.php @@ -52,6 +52,7 @@ protected function makeExecutionFunction(?Relation $parentRelation = null): call return new UpsertModel( new SaveModel($parentRelation), $this->directiveArgValue('identifyingColumns'), + $parentRelation, ); } diff --git a/src/lighthouse.php b/src/lighthouse.php index cd56d17277..0f336d56e9 100644 --- a/src/lighthouse.php +++ b/src/lighthouse.php @@ -402,6 +402,18 @@ 'shortcut_foreign_key_selection' => false, + /* + |-------------------------------------------------------------------------- + | Nested Mutations Scope To Parent + |-------------------------------------------------------------------------- + | + | If set to true, nested delete, disconnect, update and upsert throw an error for models not related to the parent. + | This option will be removed in v7, which always scopes nested mutations to the parent. + | + */ + + 'nested_mutations_scope_to_parent' => false, + /* |-------------------------------------------------------------------------- | GraphQL Subscriptions diff --git a/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php b/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php index e164f7b50f..b051cc9b1f 100644 --- a/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php +++ b/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php @@ -3,6 +3,7 @@ namespace Tests\Integration\Execution\MutationExecutor; use Faker\Provider\Lorem; +use Nuwave\Lighthouse\Execution\Arguments\ParentRelationScope; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\Role; @@ -1099,4 +1100,310 @@ public function testConnectUserWithRoleAndPivotMetaByUsingConnect(): void ], ]); } + + public function testDeleteBelongsToManyWithoutRelationFailsWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $role = factory(Role::class)->create(); + $otherRole = factory(Role::class)->create(); + $otherRolesUser = factory(User::class)->create(); + $otherRole->users()->attach($otherRolesUser); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($roleID: ID!, $userID: ID!) { + updateRole(input: { + id: $roleID + users: { + delete: [$userID] + } + }) { + id + } + } + GRAPHQL, [ + 'roleID' => $role->id, + 'userID' => $otherRolesUser->id, + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherRolesUser->id])); + + $this->assertNotNull(User::find($otherRolesUser->id)); + $this->assertCount(1, $otherRole->users()->get()); + } + + public function testDeleteBelongsToManyWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $role = factory(Role::class)->create(); + $user = factory(User::class)->create(); + $role->users()->attach($user); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($roleID: ID!, $userID: ID!) { + updateRole(input: { + id: $roleID + users: { + delete: [$userID] + } + }) { + users { + id + } + } + } + GRAPHQL, [ + 'roleID' => $role->id, + 'userID' => $user->id, + ])->assertExactJson([ + 'data' => [ + 'updateRole' => [ + 'users' => [], + ], + ], + ]); + + $this->assertNull(User::find($user->id)); + } + + /** @return iterable */ + public static function scopeToParent(): iterable + { + yield 'unscoped' => [false]; + yield 'scoped to parent' => [true]; + } + + /** @dataProvider scopeToParent */ + #[DataProvider('scopeToParent')] + public function testDeleteBelongsToManyWithSingleID(bool $scopeToParent): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => $scopeToParent]); + + $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' + type Role { + id: ID! + users: [User!] @belongsToMany + } + + type User { + id: ID! + } + + type Mutation { + updateRole(input: UpdateRoleInput! @spread): Role @update + } + + input UpdateRoleInput { + id: ID! + users: DeleteUserRelation + } + + input DeleteUserRelation { + delete: ID + } + GRAPHQL . self::PLACEHOLDER_QUERY; + + $role = factory(Role::class)->create(); + $user = factory(User::class)->create(); + $role->users()->attach($user); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($roleID: ID!, $userID: ID!) { + updateRole(input: { + id: $roleID + users: { + delete: $userID + } + }) { + users { + id + } + } + } + GRAPHQL, [ + 'roleID' => $role->id, + 'userID' => $user->id, + ])->assertExactJson([ + 'data' => [ + 'updateRole' => [ + 'users' => [], + ], + ], + ]); + + $this->assertNull(User::find($user->id)); + } + + public function testUpdateBelongsToManyWithoutRelationFailsWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $role = factory(Role::class)->create(); + $otherRole = factory(Role::class)->create(); + $otherRolesUser = factory(User::class)->make(); + $otherRolesUser->name = 'original'; + $otherRolesUser->save(); + $otherRole->users()->attach($otherRolesUser); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($roleID: ID!, $userID: ID!) { + updateRole(input: { + id: $roleID + users: { + update: [{ id: $userID, name: "hacked" }] + } + }) { + id + } + } + GRAPHQL, [ + 'roleID' => $role->id, + 'userID' => $otherRolesUser->id, + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherRolesUser->id])); + + $otherRolesUser->refresh(); + + $this->assertSame('original', $otherRolesUser->name); + $this->assertCount(0, $role->users()->get()); + } + + public function testUpsertBelongsToManyWithoutRelationFailsWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $role = factory(Role::class)->create(); + $otherRole = factory(Role::class)->create(); + $otherRolesUser = factory(User::class)->make(); + $otherRolesUser->name = 'original'; + $otherRolesUser->save(); + $otherRole->users()->attach($otherRolesUser); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($roleID: ID!, $userID: ID!) { + updateRole(input: { + id: $roleID + users: { + upsert: [{ id: $userID, name: "hacked" }] + } + }) { + id + } + } + GRAPHQL, [ + 'roleID' => $role->id, + 'userID' => $otherRolesUser->id, + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherRolesUser->id])); + + $otherRolesUser->refresh(); + + $this->assertSame('original', $otherRolesUser->name); + $this->assertCount(0, $role->users()->get()); + } + + public function testUpdateBelongsToManyWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $role = factory(Role::class)->create(); + $user = factory(User::class)->create(); + $role->users()->attach($user); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($roleID: ID!, $userID: ID!) { + updateRole(input: { + id: $roleID + users: { + update: [{ id: $userID, name: "updated" }] + } + }) { + users { + id + name + } + } + } + GRAPHQL, [ + 'roleID' => $role->id, + 'userID' => $user->id, + ])->assertExactJson([ + 'data' => [ + 'updateRole' => [ + 'users' => [ + [ + 'id' => (string) $user->id, + 'name' => 'updated', + ], + ], + ], + ], + ]); + } + + public function testDeleteBelongsToManyWithOwnAndForeignIDsFailsWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $role = factory(Role::class)->create(); + $user = factory(User::class)->create(); + $role->users()->attach($user); + $otherUser = factory(User::class)->create(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($roleID: ID!, $userIDs: [ID!]!) { + updateRole(input: { + id: $roleID + users: { + delete: $userIDs + } + }) { + id + } + } + GRAPHQL, [ + 'roleID' => $role->id, + 'userIDs' => [$user->id, $otherUser->id], + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherUser->id])); + + $this->assertNotNull(User::find($user->id)); + $this->assertNotNull(User::find($otherUser->id)); + $this->assertCount(1, $role->users()->get()); + } + + public function testUpsertBelongsToManyWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $role = factory(Role::class)->create(); + $user = factory(User::class)->create(); + $role->users()->attach($user); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($roleID: ID!, $userID: ID!) { + updateRole(input: { + id: $roleID + users: { + upsert: [{ id: $userID, name: "upserted" }] + } + }) { + users { + id + name + } + } + } + GRAPHQL, [ + 'roleID' => $role->id, + 'userID' => $user->id, + ])->assertExactJson([ + 'data' => [ + 'updateRole' => [ + 'users' => [ + [ + 'id' => (string) $user->id, + 'name' => 'upserted', + ], + ], + ], + ], + ]); + } } diff --git a/tests/Integration/Execution/MutationExecutor/BelongsToTest.php b/tests/Integration/Execution/MutationExecutor/BelongsToTest.php index 660a9f4575..70fcd31461 100644 --- a/tests/Integration/Execution/MutationExecutor/BelongsToTest.php +++ b/tests/Integration/Execution/MutationExecutor/BelongsToTest.php @@ -1312,4 +1312,46 @@ public function testCreateMultipleBelongsToThatDontExistYetWithExistingRecords() ], ]); } + + public function testUpdateBelongsToAttachesUnrelatedModelWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $task = factory(Task::class)->create(); + $otherUser = factory(User::class)->create(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($taskID: ID!, $userID: ID!) { + upsertTask(input: { + id: $taskID + user: { + update: { id: $userID, name: "updated" } + } + }) { + user { + id + name + } + } + } + GRAPHQL, [ + 'taskID' => $task->id, + 'userID' => $otherUser->id, + ])->assertExactJson([ + 'data' => [ + 'upsertTask' => [ + 'user' => [ + 'id' => (string) $otherUser->id, + 'name' => 'updated', + ], + ], + ], + ]); + + $task->refresh(); + $this->assertSame($otherUser->id, $task->user_id); + + $otherUser->refresh(); + $this->assertSame('updated', $otherUser->name); + } } diff --git a/tests/Integration/Execution/MutationExecutor/HasManyTest.php b/tests/Integration/Execution/MutationExecutor/HasManyTest.php index bf564e1de9..9474347767 100644 --- a/tests/Integration/Execution/MutationExecutor/HasManyTest.php +++ b/tests/Integration/Execution/MutationExecutor/HasManyTest.php @@ -2,6 +2,7 @@ namespace Tests\Integration\Execution\MutationExecutor; +use Nuwave\Lighthouse\Execution\Arguments\ParentRelationScope; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\CustomPrimaryKey; @@ -888,4 +889,468 @@ public function testUpdateNestedHasMany(): void ], ]); } + + public function testDeleteHasManyWithoutRelationFailsWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $user = factory(User::class)->create(); + $otherUsersTask = factory(Task::class)->create(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($userID: ID!, $taskID: ID!) { + updateUser(input: { + id: $userID + tasks: { + delete: [$taskID] + } + }) { + id + } + } + GRAPHQL, [ + 'userID' => $user->id, + 'taskID' => $otherUsersTask->id, + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherUsersTask->id])); + + $this->assertNotNull(Task::find($otherUsersTask->id)); + } + + public function testDeleteHasManyWithMissingIDFailsWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $user = factory(User::class)->create(); + $task = factory(Task::class)->make(); + $task->user()->associate($user); + $task->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($userID: ID!, $taskID: ID!) { + updateUser(input: { + id: $userID + tasks: { + delete: [$taskID, 999] + } + }) { + id + } + } + GRAPHQL, [ + 'userID' => $user->id, + 'taskID' => $task->id, + ])->assertGraphQLErrorMessage('Cannot modify a model that is not related to the given parent: 999.'); + + $this->assertNotNull(Task::find($task->id)); + } + + public function testDeleteHasManyWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $user = factory(User::class)->create(); + $task = factory(Task::class)->make(); + $task->user()->associate($user); + $task->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($userID: ID!, $taskID: ID!) { + updateUser(input: { + id: $userID + tasks: { + delete: [$taskID] + } + }) { + tasks { + id + } + } + } + GRAPHQL, [ + 'userID' => $user->id, + 'taskID' => $task->id, + ])->assertExactJson([ + 'data' => [ + 'updateUser' => [ + 'tasks' => [], + ], + ], + ]); + + $this->assertNull(Task::find($task->id)); + } + + /** @return iterable */ + public static function scopeToParent(): iterable + { + yield 'unscoped' => [false]; + yield 'scoped to parent' => [true]; + } + + /** @dataProvider scopeToParent */ + #[DataProvider('scopeToParent')] + public function testDeleteHasManyWithSingleID(bool $scopeToParent): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => $scopeToParent]); + + $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' + type Task { + id: ID! + } + + type User { + id: ID! + tasks: [Task!]! @hasMany + } + + type Mutation { + updateUser(input: UpdateUserInput! @spread): User @update + } + + input UpdateUserInput { + id: ID! + tasks: DeleteTaskRelation + } + + input DeleteTaskRelation { + delete: ID + } + GRAPHQL . self::PLACEHOLDER_QUERY; + + $user = factory(User::class)->create(); + $task = factory(Task::class)->make(); + $task->user()->associate($user); + $task->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($userID: ID!, $taskID: ID!) { + updateUser(input: { + id: $userID + tasks: { + delete: $taskID + } + }) { + tasks { + id + } + } + } + GRAPHQL, [ + 'userID' => $user->id, + 'taskID' => $task->id, + ])->assertExactJson([ + 'data' => [ + 'updateUser' => [ + 'tasks' => [], + ], + ], + ]); + + $this->assertNull(Task::find($task->id)); + } + + public function testDisconnectHasManyWithoutRelationFailsWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $user = factory(User::class)->create(); + $otherUser = factory(User::class)->create(); + $otherUsersTask = factory(Task::class)->make(); + $otherUsersTask->user()->associate($otherUser); + $otherUsersTask->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($userID: ID!, $taskID: ID!) { + updateUser(input: { + id: $userID + tasks: { + disconnect: [$taskID] + } + }) { + id + } + } + GRAPHQL, [ + 'userID' => $user->id, + 'taskID' => $otherUsersTask->id, + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherUsersTask->id])); + + $otherUsersTask->refresh(); + + $this->assertSame($otherUser->id, $otherUsersTask->user_id); + } + + public function testDisconnectHasManyWithoutRelationDetachesFromOtherParent(): void + { + $user = factory(User::class)->create(); + $otherUser = factory(User::class)->create(); + $otherUsersTask = factory(Task::class)->make(); + $otherUsersTask->user()->associate($otherUser); + $otherUsersTask->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($userID: ID!, $taskID: ID!) { + updateUser(input: { + id: $userID + tasks: { + disconnect: [$taskID] + } + }) { + id + } + } + GRAPHQL, [ + 'userID' => $user->id, + 'taskID' => $otherUsersTask->id, + ])->assertGraphQLErrorFree(); + + $otherUsersTask->refresh(); + + $this->assertNull($otherUsersTask->user_id); + } + + public function testUpdateHasManyWithoutRelationFailsWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $user = factory(User::class)->create(); + $otherUser = factory(User::class)->create(); + $otherUsersTask = factory(Task::class)->make(); + $otherUsersTask->name = 'original'; + $otherUsersTask->user()->associate($otherUser); + $otherUsersTask->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($userID: ID!, $taskID: ID!) { + updateUser(input: { + id: $userID + tasks: { + update: [{ id: $taskID, name: "hacked" }] + } + }) { + id + } + } + GRAPHQL, [ + 'userID' => $user->id, + 'taskID' => $otherUsersTask->id, + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherUsersTask->id])); + + $otherUsersTask->refresh(); + $this->assertSame('original', $otherUsersTask->name); + $this->assertSame($otherUser->id, $otherUsersTask->user_id); + } + + public function testUpsertHasManyWithoutRelationFailsWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $user = factory(User::class)->create(); + $otherUser = factory(User::class)->create(); + $otherUsersTask = factory(Task::class)->make(); + $otherUsersTask->name = 'original'; + $otherUsersTask->user()->associate($otherUser); + $otherUsersTask->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($userID: ID!, $taskID: ID!) { + updateUser(input: { + id: $userID + tasks: { + upsert: [{ id: $taskID, name: "hacked" }] + } + }) { + id + } + } + GRAPHQL, [ + 'userID' => $user->id, + 'taskID' => $otherUsersTask->id, + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherUsersTask->id])); + + $otherUsersTask->refresh(); + $this->assertSame('original', $otherUsersTask->name); + $this->assertSame($otherUser->id, $otherUsersTask->user_id); + } + + public function testUpsertHasManyWithMissingIDCreatesWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $user = factory(User::class)->create(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($userID: ID!) { + updateUser(input: { + id: $userID + tasks: { + upsert: [{ id: 999, name: "new" }] + } + }) { + tasks { + id + name + } + } + } + GRAPHQL, [ + 'userID' => $user->id, + ])->assertExactJson([ + 'data' => [ + 'updateUser' => [ + 'tasks' => [ + [ + 'id' => '999', + 'name' => 'new', + ], + ], + ], + ], + ]); + } + + public function testUpdateHasManyWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $user = factory(User::class)->create(); + $task = factory(Task::class)->make(); + $task->user()->associate($user); + $task->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($userID: ID!, $taskID: ID!) { + updateUser(input: { + id: $userID + tasks: { + update: [{ id: $taskID, name: "updated" }] + } + }) { + tasks { + name + } + } + } + GRAPHQL, [ + 'userID' => $user->id, + 'taskID' => $task->id, + ])->assertExactJson([ + 'data' => [ + 'updateUser' => [ + 'tasks' => [ + [ + 'name' => 'updated', + ], + ], + ], + ], + ]); + } + + public function testUpsertHasManyWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $user = factory(User::class)->create(); + $task = factory(Task::class)->make(); + $task->user()->associate($user); + $task->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($userID: ID!, $taskID: ID!) { + updateUser(input: { + id: $userID + tasks: { + upsert: [{ id: $taskID, name: "upserted" }] + } + }) { + tasks { + name + } + } + } + GRAPHQL, [ + 'userID' => $user->id, + 'taskID' => $task->id, + ])->assertExactJson([ + 'data' => [ + 'updateUser' => [ + 'tasks' => [ + [ + 'name' => 'upserted', + ], + ], + ], + ], + ]); + + $this->assertSame(1, Task::count()); + } + + public function testDisconnectHasManyWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $user = factory(User::class)->create(); + $task = factory(Task::class)->make(); + $task->user()->associate($user); + $task->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($userID: ID!, $taskID: ID!) { + updateUser(input: { + id: $userID + tasks: { + disconnect: [$taskID] + } + }) { + tasks { + id + } + } + } + GRAPHQL, [ + 'userID' => $user->id, + 'taskID' => $task->id, + ])->assertExactJson([ + 'data' => [ + 'updateUser' => [ + 'tasks' => [], + ], + ], + ]); + + $task->refresh(); + $this->assertNull($task->user_id); + } + + public function testDisconnectHasManyWithOwnAndForeignIDsFailsWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $user = factory(User::class)->create(); + $task = factory(Task::class)->make(); + $task->user()->associate($user); + $task->save(); + $otherUsersTask = factory(Task::class)->create(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($userID: ID!, $taskIDs: [ID!]!) { + updateUser(input: { + id: $userID + tasks: { + disconnect: $taskIDs + } + }) { + id + } + } + GRAPHQL, [ + 'userID' => $user->id, + 'taskIDs' => [$task->id, $otherUsersTask->id], + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherUsersTask->id])); + + $task->refresh(); + $this->assertSame($user->id, $task->user_id); + } } diff --git a/tests/Integration/Execution/MutationExecutor/HasOneTest.php b/tests/Integration/Execution/MutationExecutor/HasOneTest.php index b6856e22c7..43b2b29470 100644 --- a/tests/Integration/Execution/MutationExecutor/HasOneTest.php +++ b/tests/Integration/Execution/MutationExecutor/HasOneTest.php @@ -2,6 +2,7 @@ namespace Tests\Integration\Execution\MutationExecutor; +use Nuwave\Lighthouse\Execution\Arguments\ParentRelationScope; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\Post; @@ -546,4 +547,130 @@ public function testUpdateAndDeleteHasOne(string $action): void ], ]); } + + public function testDeleteHasOneWithoutRelationFailsWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $task = factory(Task::class)->create(); + $otherTasksPost = factory(Post::class)->create(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($taskID: ID!, $postID: ID!) { + updateTask(input: { + id: $taskID + post: { + delete: $postID + } + }) { + id + } + } + GRAPHQL, [ + 'taskID' => $task->id, + 'postID' => $otherTasksPost->id, + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherTasksPost->id])); + + $this->assertNotNull(Post::find($otherTasksPost->id)); + } + + public function testUpdateHasOneWithoutRelationFailsWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $task = factory(Task::class)->create(); + $otherTask = factory(Task::class)->create(); + $otherTasksPost = factory(Post::class)->make(); + $otherTasksPost->title = 'original'; + $otherTasksPost->task()->associate($otherTask); + $otherTasksPost->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($taskID: ID!, $postID: ID!) { + updateTask(input: { + id: $taskID + post: { + update: { id: $postID, title: "hacked" } + } + }) { + id + } + } + GRAPHQL, [ + 'taskID' => $task->id, + 'postID' => $otherTasksPost->id, + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherTasksPost->id])); + + $otherTasksPost->refresh(); + $this->assertSame('original', $otherTasksPost->title); + $this->assertSame($otherTask->id, $otherTasksPost->task_id); + } + + public function testUpsertHasOneWithoutRelationFailsWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $task = factory(Task::class)->create(); + $otherTask = factory(Task::class)->create(); + $otherTasksPost = factory(Post::class)->make(); + $otherTasksPost->title = 'original'; + $otherTasksPost->task()->associate($otherTask); + $otherTasksPost->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($taskID: ID!, $postID: ID!) { + updateTask(input: { + id: $taskID + post: { + upsert: { id: $postID, title: "hacked" } + } + }) { + id + } + } + GRAPHQL, [ + 'taskID' => $task->id, + 'postID' => $otherTasksPost->id, + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherTasksPost->id])); + + $otherTasksPost->refresh(); + $this->assertSame('original', $otherTasksPost->title); + $this->assertSame($otherTask->id, $otherTasksPost->task_id); + } + + public function testUpdateHasOneWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $task = factory(Task::class)->create(); + $post = factory(Post::class)->make(); + $post->task()->associate($task); + $post->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($taskID: ID!, $postID: ID!) { + updateTask(input: { + id: $taskID + post: { + update: { id: $postID, title: "updated" } + } + }) { + post { + title + } + } + } + GRAPHQL, [ + 'taskID' => $task->id, + 'postID' => $post->id, + ])->assertExactJson([ + 'data' => [ + 'updateTask' => [ + 'post' => [ + 'title' => 'updated', + ], + ], + ], + ]); + } } diff --git a/tests/Integration/Execution/MutationExecutor/MorphManyTest.php b/tests/Integration/Execution/MutationExecutor/MorphManyTest.php index 497cd79de4..3d65f0dda6 100644 --- a/tests/Integration/Execution/MutationExecutor/MorphManyTest.php +++ b/tests/Integration/Execution/MutationExecutor/MorphManyTest.php @@ -2,6 +2,7 @@ namespace Tests\Integration\Execution\MutationExecutor; +use Nuwave\Lighthouse\Execution\Arguments\ParentRelationScope; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\Image; @@ -584,4 +585,65 @@ public function testDisconnectModelEventsMorphMany(string $action): void $this->assertSame(1, $calledImageSaving); } + + public function testDeleteMorphManyWithoutRelationFailsWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $task = factory(Task::class)->create(); + $otherTask = factory(Task::class)->create(); + $otherTasksImage = factory(Image::class)->make(); + $otherTasksImage->imageable()->associate($otherTask); + $otherTasksImage->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($taskID: ID!, $imageID: ID!) { + updateTask(input: { + id: $taskID + images: { + delete: [$imageID] + } + }) { + id + } + } + GRAPHQL, [ + 'taskID' => $task->id, + 'imageID' => $otherTasksImage->id, + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherTasksImage->id])); + + $this->assertNotNull(Image::find($otherTasksImage->id)); + } + + public function testUpsertMorphManyWithoutRelationFailsWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $task = factory(Task::class)->create(); + $otherTask = factory(Task::class)->create(); + $otherTasksImage = factory(Image::class)->make(); + $otherTasksImage->url = 'original'; + $otherTasksImage->imageable()->associate($otherTask); + $otherTasksImage->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($taskID: ID!, $imageID: ID!) { + updateTask(input: { + id: $taskID + images: { + upsert: [{ id: $imageID, url: "hacked" }] + } + }) { + id + } + } + GRAPHQL, [ + 'taskID' => $task->id, + 'imageID' => $otherTasksImage->id, + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherTasksImage->id])); + + $otherTasksImage->refresh(); + $this->assertSame('original', $otherTasksImage->url); + $this->assertSame($otherTask->id, $otherTasksImage->imageable_id); + } } diff --git a/tests/Integration/Execution/MutationExecutor/MorphOneTest.php b/tests/Integration/Execution/MutationExecutor/MorphOneTest.php index 915e287a39..d29229cb5b 100644 --- a/tests/Integration/Execution/MutationExecutor/MorphOneTest.php +++ b/tests/Integration/Execution/MutationExecutor/MorphOneTest.php @@ -2,6 +2,7 @@ namespace Tests\Integration\Execution\MutationExecutor; +use Nuwave\Lighthouse\Execution\Arguments\ParentRelationScope; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\Image; @@ -415,4 +416,36 @@ public function testNestedConnectMorphOne(): void ], ]); } + + public function testUpsertMorphOneWithoutRelationFailsWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $task = factory(Task::class)->create(); + $otherTask = factory(Task::class)->create(); + $otherTasksImage = factory(Image::class)->make(); + $otherTasksImage->url = 'original'; + $otherTasksImage->imageable()->associate($otherTask); + $otherTasksImage->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($taskID: ID!, $imageID: ID!) { + updateTask(input: { + id: $taskID + image: { + upsert: { id: $imageID, url: "hacked" } + } + }) { + id + } + } + GRAPHQL, [ + 'taskID' => $task->id, + 'imageID' => $otherTasksImage->id, + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherTasksImage->id])); + + $otherTasksImage->refresh(); + $this->assertSame('original', $otherTasksImage->url); + $this->assertSame($otherTask->id, $otherTasksImage->imageable_id); + } } diff --git a/tests/Integration/Execution/MutationExecutor/MorphToManyTest.php b/tests/Integration/Execution/MutationExecutor/MorphToManyTest.php index 2114e23f3b..78ddbfb66d 100644 --- a/tests/Integration/Execution/MutationExecutor/MorphToManyTest.php +++ b/tests/Integration/Execution/MutationExecutor/MorphToManyTest.php @@ -2,8 +2,10 @@ namespace Tests\Integration\Execution\MutationExecutor; +use Nuwave\Lighthouse\Execution\Arguments\ParentRelationScope; use Tests\DBTestCase; use Tests\Utils\Models\Tag; +use Tests\Utils\Models\Task; final class MorphToManyTest extends DBTestCase { @@ -362,4 +364,38 @@ public function testUpsertMorphToManyWithoutId(): void ], ]); } + + public function testUpsertMorphToManyWithoutRelationFailsWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $task = factory(Task::class)->create(); + $otherTask = factory(Task::class)->create(); + $otherTasksTag = factory(Tag::class)->make(); + $otherTasksTag->name = 'original'; + $otherTasksTag->save(); + $otherTask->tags()->attach($otherTasksTag); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($taskID: ID!, $tagID: ID!) { + upsertTask(input: { + id: $taskID + name: "task" + tags: { + upsert: [{ id: $tagID, name: "hacked" }] + } + }) { + id + } + } + GRAPHQL, [ + 'taskID' => $task->id, + 'tagID' => $otherTasksTag->id, + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherTasksTag->id])); + + $otherTasksTag->refresh(); + + $this->assertSame('original', $otherTasksTag->name); + $this->assertCount(0, $task->tags()->get()); + } } diff --git a/tests/Integration/Schema/Directives/DeleteDirectiveTest.php b/tests/Integration/Schema/Directives/DeleteDirectiveTest.php index 9d7d8e2363..4f761f7636 100644 --- a/tests/Integration/Schema/Directives/DeleteDirectiveTest.php +++ b/tests/Integration/Schema/Directives/DeleteDirectiveTest.php @@ -4,9 +4,11 @@ use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Nuwave\Lighthouse\Exceptions\DefinitionException; +use Nuwave\Lighthouse\Execution\Arguments\ParentRelationScope; use Nuwave\Lighthouse\Schema\Directives\ModifyModelExistenceDirective; use Tests\DBTestCase; use Tests\Utils\Models\Post; +use Tests\Utils\Models\Role; use Tests\Utils\Models\Task; use Tests\Utils\Models\User; @@ -396,6 +398,125 @@ public function testUseNestedArgResolverDelete(): void ]); } + public function testNestedArgResolverDeleteWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $user = factory(User::class)->create(); + $task = factory(Task::class)->make(); + $task->user()->associate($user); + $task->save(); + + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + updateUser( + id: ID + deleteTasks: [ID!]! @delete(relation: "tasks") + ): User @update + } + + type User { + id: ID! + tasks: [Task!]! + } + + type Task { + id: ID! + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($id: ID!, $deleteTasks: [ID!]!) { + updateUser(id: $id, deleteTasks: $deleteTasks) { + tasks { + id + } + } + } + GRAPHQL, [ + 'id' => $user->id, + 'deleteTasks' => [$task->id], + ])->assertExactJson([ + 'data' => [ + 'updateUser' => [ + 'tasks' => [], + ], + ], + ]); + + $this->assertNull(Task::find($task->id)); + } + + public function testNestedArgResolverDeleteWithoutRelationFailsWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $user = factory(User::class)->create(); + $otherUsersTask = factory(Task::class)->create(); + + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + updateUser( + id: ID + deleteTasks: [ID!]! @delete(relation: "tasks") + ): User @update + } + + type User { + id: ID! + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($id: ID!, $deleteTasks: [ID!]!) { + updateUser(id: $id, deleteTasks: $deleteTasks) { + id + } + } + GRAPHQL, [ + 'id' => $user->id, + 'deleteTasks' => [$otherUsersTask->id], + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherUsersTask->id])); + + $this->assertNotNull(Task::find($otherUsersTask->id)); + } + + public function testNestedArgResolverDeleteBelongsToManyWithoutRelationFailsWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $user = factory(User::class)->create(); + $otherUsersRole = factory(Role::class)->create(); + $otherUsersRole->users()->attach(factory(User::class)->create()); + + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + updateUser( + id: ID + deleteRoles: [ID!]! @delete(relation: "roles") + ): User @update + } + + type User { + id: ID! + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($id: ID!, $deleteRoles: [ID!]!) { + updateUser(id: $id, deleteRoles: $deleteRoles) { + id + } + } + GRAPHQL, [ + 'id' => $user->id, + 'deleteRoles' => [$otherUsersRole->id], + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherUsersRole->id])); + + $this->assertNotNull(Role::find($otherUsersRole->id)); + $this->assertCount(1, $otherUsersRole->users()->get()); + } + public function testDeleteHasOneThroughNestedArgResolver(): void { $task = factory(Task::class)->create(); diff --git a/tests/Integration/Schema/Directives/UpdateDirectiveTest.php b/tests/Integration/Schema/Directives/UpdateDirectiveTest.php index fb0839dc88..efe760fe8f 100644 --- a/tests/Integration/Schema/Directives/UpdateDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpdateDirectiveTest.php @@ -3,6 +3,7 @@ namespace Tests\Integration\Schema\Directives; use Illuminate\Database\QueryException; +use Nuwave\Lighthouse\Execution\Arguments\ParentRelationScope; use Nuwave\Lighthouse\Execution\Arguments\UpdateModel; use Tests\DBTestCase; use Tests\Utils\Models\Category; @@ -587,4 +588,52 @@ public function testNestChildNamedIdDoesNotClobberPrimaryKey(): void $decoy->refresh(); $this->assertSame('Decoy', $decoy->name); } + + public function testNestedUpdateWithoutRelationFailsWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $user = factory(User::class)->create(); + $otherUsersTask = factory(Task::class)->make(); + $otherUsersTask->name = 'original'; + $otherUsersTask->user()->associate(factory(User::class)->create()); + $otherUsersTask->save(); + + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + updateUser(input: UpdateUserInput! @spread): User @update + } + + type User { + id: Int + } + + input UpdateUserInput { + id: Int + updateTask: UpdateTaskInput @update(relation: "tasks") + } + + input UpdateTaskInput { + id: Int + name: String + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($userID: Int!, $taskID: Int!) { + updateUser(input: { + id: $userID + updateTask: { id: $taskID, name: "hacked" } + }) { + id + } + } + GRAPHQL, [ + 'userID' => $user->id, + 'taskID' => $otherUsersTask->id, + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherUsersTask->id])); + + $otherUsersTask->refresh(); + $this->assertSame('original', $otherUsersTask->name); + } } diff --git a/tests/Integration/Schema/Directives/UpdateManyDirectiveTest.php b/tests/Integration/Schema/Directives/UpdateManyDirectiveTest.php index 2b284cede9..9db34d2d89 100644 --- a/tests/Integration/Schema/Directives/UpdateManyDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpdateManyDirectiveTest.php @@ -4,9 +4,12 @@ use Illuminate\Database\Eloquent\ModelNotFoundException; use Nuwave\Lighthouse\Exceptions\DefinitionException; +use Nuwave\Lighthouse\Execution\Arguments\ParentRelationScope; use Nuwave\Lighthouse\Schema\Directives\ManyModelMutationDirective; use Tests\DBTestCase; use Tests\Utils\Models\Company; +use Tests\Utils\Models\Task; +use Tests\Utils\Models\User; final class UpdateManyDirectiveTest extends DBTestCase { @@ -327,4 +330,52 @@ public function testNotInputObjects(): void } GRAPHQL); } + + public function testNestedUpdateManyWithoutRelationFailsWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $user = factory(User::class)->create(); + $otherUsersTask = factory(Task::class)->make(); + $otherUsersTask->name = 'original'; + $otherUsersTask->user()->associate(factory(User::class)->create()); + $otherUsersTask->save(); + + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + updateUser(input: UpdateUserInput! @spread): User @update + } + + type User { + id: Int + } + + input UpdateUserInput { + id: Int + tasks: [UpdateTaskInput!] @updateMany(relation: "tasks") + } + + input UpdateTaskInput { + id: Int + name: String + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($userID: Int!, $taskID: Int!) { + updateUser(input: { + id: $userID + tasks: [{ id: $taskID, name: "hacked" }] + }) { + id + } + } + GRAPHQL, [ + 'userID' => $user->id, + 'taskID' => $otherUsersTask->id, + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherUsersTask->id])); + + $otherUsersTask->refresh(); + $this->assertSame('original', $otherUsersTask->name); + } } diff --git a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php index e7cdb9feeb..282f900230 100644 --- a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php @@ -5,6 +5,7 @@ use GraphQL\Type\Definition\Type; use Illuminate\Container\Container; use Nuwave\Lighthouse\Exceptions\DefinitionException; +use Nuwave\Lighthouse\Execution\Arguments\ParentRelationScope; use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use Nuwave\Lighthouse\Schema\TypeRegistry; use Tests\DBTestCase; @@ -660,4 +661,187 @@ public static function resolveType(): Type return $typeRegistry->get('Admin'); } + + public function testNestedUpsertByIdentifyingColumnWithoutRelationFailsWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $user = factory(User::class)->create(); + $otherUser = factory(User::class)->create(); + $otherUsersTask = factory(Task::class)->make(); + $otherUsersTask->name = 'existing-task'; + $otherUsersTask->difficulty = 1; + $otherUsersTask->user()->associate($otherUser); + $otherUsersTask->save(); + + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + updateUser(input: UpdateUserInput! @spread): User @update + } + + type Task { + id: Int + } + + type User { + id: Int + } + + input UpdateUserInput { + id: Int + tasks: [UpdateTaskInput!] @upsert(relation: "tasks", identifyingColumns: ["name"]) + } + + input UpdateTaskInput { + name: String! + difficulty: Int + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($userID: Int!) { + updateUser(input: { + id: $userID + tasks: [{ name: "existing-task", difficulty: 2 }] + }) { + id + } + } + GRAPHQL, [ + 'userID' => $user->id, + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel(['existing-task'])); + + $otherUsersTask->refresh(); + $this->assertSame(1, $otherUsersTask->difficulty); + $this->assertSame($otherUser->id, $otherUsersTask->user_id); + } + + public function testNestedUpsertByIdentifyingColumnUpdatesRelatedModelWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $user = factory(User::class)->create(); + $task = factory(Task::class)->make(); + $task->name = 'existing-task'; + $task->difficulty = 1; + $task->user()->associate($user); + $task->save(); + + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + updateUser(input: UpdateUserInput! @spread): User @update + } + + type Task { + name: String + difficulty: Int + } + + type User { + id: Int + tasks: [Task!]! @hasMany + } + + input UpdateUserInput { + id: Int + tasks: [UpdateTaskInput!] @upsert(relation: "tasks", identifyingColumns: ["name"]) + } + + input UpdateTaskInput { + name: String! + difficulty: Int + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($userID: Int!) { + updateUser(input: { + id: $userID + tasks: [{ name: "existing-task", difficulty: 2 }] + }) { + tasks { + name + difficulty + } + } + } + GRAPHQL, [ + 'userID' => $user->id, + ])->assertExactJson([ + 'data' => [ + 'updateUser' => [ + 'tasks' => [ + [ + 'name' => 'existing-task', + 'difficulty' => 2, + ], + ], + ], + ], + ]); + + $this->assertSame(1, Task::count()); + } + + public function testNestedUpsertByIdentifyingColumnCreatesWhenNothingMatchesWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $user = factory(User::class)->create(); + + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + updateUser(input: UpdateUserInput! @spread): User @update + } + + type Task { + name: String + difficulty: Int + } + + type User { + id: Int + tasks: [Task!]! @hasMany + } + + input UpdateUserInput { + id: Int + tasks: [UpdateTaskInput!] @upsert(relation: "tasks", identifyingColumns: ["name"]) + } + + input UpdateTaskInput { + name: String! + difficulty: Int + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($userID: Int!) { + updateUser(input: { + id: $userID + tasks: [{ name: "existing-task", difficulty: 2 }] + }) { + tasks { + name + difficulty + } + } + } + GRAPHQL, [ + 'userID' => $user->id, + ])->assertExactJson([ + 'data' => [ + 'updateUser' => [ + 'tasks' => [ + [ + 'name' => 'existing-task', + 'difficulty' => 2, + ], + ], + ], + ], + ]); + + $this->assertSame(1, Task::count()); + } } diff --git a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php index e582b9c9ee..07a5b25930 100644 --- a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php @@ -5,6 +5,7 @@ use GraphQL\Type\Definition\Type; use Illuminate\Container\Container; use Nuwave\Lighthouse\Exceptions\DefinitionException; +use Nuwave\Lighthouse\Execution\Arguments\ParentRelationScope; use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use Nuwave\Lighthouse\Schema\TypeRegistry; use Tests\DBTestCase; @@ -535,4 +536,52 @@ public static function resolveType(): Type return $typeRegistry->get('Admin'); } + + public function testNestedUpsertManyWithoutRelationFailsWhenScopedToParent(): void + { + config(['lighthouse.nested_mutations_scope_to_parent' => true]); + + $user = factory(User::class)->create(); + $otherUsersTask = factory(Task::class)->make(); + $otherUsersTask->name = 'original'; + $otherUsersTask->user()->associate(factory(User::class)->create()); + $otherUsersTask->save(); + + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + updateUser(input: UpdateUserInput! @spread): User @update + } + + type User { + id: Int + } + + input UpdateUserInput { + id: Int + tasks: [UpdateTaskInput!] @upsertMany(relation: "tasks") + } + + input UpdateTaskInput { + id: Int + name: String + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($userID: Int!, $taskID: Int!) { + updateUser(input: { + id: $userID + tasks: [{ id: $taskID, name: "hacked" }] + }) { + id + } + } + GRAPHQL, [ + 'userID' => $user->id, + 'taskID' => $otherUsersTask->id, + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherUsersTask->id])); + + $otherUsersTask->refresh(); + $this->assertSame('original', $otherUsersTask->name); + } } diff --git a/tests/Unit/Execution/Arguments/ParentRelationScopeTest.php b/tests/Unit/Execution/Arguments/ParentRelationScopeTest.php new file mode 100644 index 0000000000..eae0b181fd --- /dev/null +++ b/tests/Unit/Execution/Arguments/ParentRelationScopeTest.php @@ -0,0 +1,17 @@ +assertSame( + 'Cannot modify a model that is not related to the given parent: a, {"b":1}.', + ParentRelationScope::unrelatedModel(['a', ['b' => 1]]), + ); + } +}