From 35eedbef39e991512c4724460572df86f7d40889 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 11 Oct 2026 13:51:14 +0200 Subject: [PATCH 01/11] Add opt-in scoping of nested mutations to the parent relation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With `nested_mutations_scope_to_parent` enabled, nested delete, disconnect, update and upsert only act on models related to the parent and throw an error otherwise. Upsert still creates a model when the given ID exists nowhere. Resolves https://github.com/nuwave/lighthouse/issues/2744 Supersedes https://github.com/nuwave/lighthouse/pull/2454 and https://github.com/nuwave/lighthouse/pull/2751 Co-authored-by: Lucas Lenz 🤖 Generated with Claude Code --- CHANGELOG.md | 4 + UPGRADE.md | 6 + docs/6/eloquent/nested-mutations.md | 10 + docs/master/eloquent/nested-mutations.md | 10 + src/Execution/Arguments/NestedManyToMany.php | 7 +- src/Execution/Arguments/NestedOneToMany.php | 25 +- src/Execution/Arguments/NestedOneToOne.php | 9 +- .../Arguments/ParentRelationScope.php | 87 ++++++ src/Execution/Arguments/UpdateModel.php | 12 +- src/Execution/Arguments/UpsertModel.php | 17 +- src/Schema/Directives/UpdateDirective.php | 2 +- src/Schema/Directives/UpdateManyDirective.php | 2 +- src/Schema/Directives/UpsertDirective.php | 1 + src/Schema/Directives/UpsertManyDirective.php | 1 + src/lighthouse.php | 13 + .../MutationExecutor/BelongsToManyTest.php | 170 ++++++++++++ .../MutationExecutor/BelongsToTest.php | 36 +++ .../MutationExecutor/HasManyTest.php | 251 ++++++++++++++++++ .../Execution/MutationExecutor/HasOneTest.php | 91 +++++++ .../MutationExecutor/MorphManyTest.php | 62 +++++ .../MutationExecutor/MorphOneTest.php | 33 +++ .../MutationExecutor/MorphToManyTest.php | 36 +++ .../Schema/Directives/UpsertDirectiveTest.php | 55 ++++ 23 files changed, 912 insertions(+), 28 deletions(-) create mode 100644 src/Execution/Arguments/ParentRelationScope.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 35933fbb7b..372b028a9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ You can find and compare releases at the [GitHub release page](https://github.co ## Unreleased +### Added + +- Add config option `nested_mutations_scope_to_parent` to limit nested `delete`, `disconnect`, `update` and `upsert` to models related to the parent https://github.com/nuwave/lighthouse/issues/2744 + ## v6.71.1 ### Fixed diff --git a/UPGRADE.md b/UPGRADE.md index 931a0fdd78..b81180760e 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -9,6 +9,12 @@ 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, its behaviour now always applies. +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..a9b30ef66f 100644 --- a/docs/6/eloquent/nested-mutations.md +++ b/docs/6/eloquent/nested-mutations.md @@ -48,6 +48,16 @@ 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. + +`connect`, `sync`, `syncWithoutDetaching` and the `update` and `upsert` operations on [BelongsTo](#belongsto) relations are not limited, because they relate models that are not yet related. +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..a9b30ef66f 100644 --- a/docs/master/eloquent/nested-mutations.md +++ b/docs/master/eloquent/nested-mutations.md @@ -48,6 +48,16 @@ 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. + +`connect`, `sync`, `syncWithoutDetaching` and the `update` and `upsert` operations on [BelongsTo](#belongsto) relations are not limited, because they relate models that are not yet related. +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 68f2f08d3b..267ced984b 100644 --- a/src/Execution/Arguments/NestedManyToMany.php +++ b/src/Execution/Arguments/NestedManyToMany.php @@ -43,7 +43,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) { // @phpstan-ignore-next-line Relation&Builder mixin not recognized @@ -52,7 +52,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) { // @phpstan-ignore-next-line Relation&Builder mixin not recognized @@ -63,8 +63,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, $ids); } if ($args->has('connect')) { diff --git a/src/Execution/Arguments/NestedOneToMany.php b/src/Execution/Arguments/NestedOneToMany.php index 8b2c216aa1..f9916316d8 100644 --- a/src/Execution/Arguments/NestedOneToMany.php +++ b/src/Execution/Arguments/NestedOneToMany.php @@ -31,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) { // @phpstan-ignore-next-line Relation&Builder mixin not recognized @@ -40,7 +40,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) { // @phpstan-ignore-next-line Relation&Builder mixin not recognized @@ -60,13 +60,16 @@ public function __invoke($model, $args): void } if ($args->has('disconnect')) { - $children = $relation - ->make() - ->whereIn( - $relation->make()->getKeyName(), - $args->arguments['disconnect']->value, - ) - ->get(); + $ids = $args->arguments['disconnect']->value; + $children = ParentRelationScope::applies($relation) + ? ParentRelationScope::findManyOrFail($relation, $ids) + : $relation + ->make() + ->whereIn( + $relation->make()->getKeyName(), + $ids, + ) + ->get(); foreach ($children as $child) { $child->setAttribute($relation->getForeignKeyName(), null); $child->save(); @@ -74,9 +77,7 @@ public function __invoke($model, $args): void } if ($args->has('delete')) { - $relation->getRelated()::destroy( - $args->arguments['delete']->toPlain(), - ); + ParentRelationScope::delete($relation, $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..a215cce274 --- /dev/null +++ b/src/Execution/Arguments/ParentRelationScope.php @@ -0,0 +1,87 @@ + $relation + */ + public static function applies(Relation $relation): bool + { + // TODO remove the config check with v7 https://github.com/nuwave/lighthouse/issues/2744 + return ! $relation instanceof BelongsTo + && config('lighthouse.nested_mutations_scope_to_parent'); + } + + /** + * @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model> $relation + * @param array $ids + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public static function findManyOrFail(Relation $relation, array $ids): EloquentCollection + { + $models = (clone $relation)->whereKey($ids)->get(); + + if (array_diff($ids, $models->modelKeys()) !== []) { + throw new Error(self::UNRELATED_MODEL); + } + + 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::applies($relation) + ? self::findManyOrFail($relation, $ids) + : $relation->getRelated()->newQuery()->whereKey($ids)->get(); + + if ($relation instanceof BelongsToMany) { + $relation->detach($ids); + } + + foreach ($models as $model) { + $model->delete(); + } + } + + /** + * Returns null if no model matches anywhere, so upsert can create one. + * + * @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model> $relation + * @param array $columns + */ + public static function firstForUpsert(Relation $relation, array $columns): ?Model + { + $related = $relation->getRelated(); + + $qualifiedColumns = []; + foreach ($columns as $column => $value) { + $qualifiedColumns[$related->qualifyColumn($column)] = $value; + } + + $model = (clone $relation)->where($qualifiedColumns)->first(); + + if ($model === null && $related->newQuery()->where($columns)->exists()) { + throw new Error(self::UNRELATED_MODEL); + } + + return $model; + } +} diff --git a/src/Execution/Arguments/UpdateModel.php b/src/Execution/Arguments/UpdateModel.php index ea7fe955fc..c3a2cae2d1 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,9 @@ 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 = $this->parentRelation !== null && ParentRelationScope::applies($this->parentRelation) + ? ParentRelationScope::findManyOrFail($this->parentRelation, [$id->value])->firstOrFail() + : $model->newQuery()->findOrFail($id->value); return ($this->previous)($model, $args); } diff --git a/src/Execution/Arguments/UpsertModel.php b/src/Execution/Arguments/UpsertModel.php index 9725812585..75e90baeea 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 = $this->findExisting($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 = $this->findExisting($model, [$model->getKeyName() => $id]); if ($existingModel !== null) { $model = $existingModel; } @@ -85,6 +88,16 @@ protected function identifyingColumnValues(ArgumentSet $args, array $identifying return $identifyingValues; } + /** @param array $columns */ + protected function findExisting(Model $model, array $columns): ?Model + { + if ($this->parentRelation !== null && ParentRelationScope::applies($this->parentRelation)) { + return ParentRelationScope::firstForUpsert($this->parentRelation, $columns); + } + + return $model->newQuery()->firstWhere($columns); + } + /** @return mixed The value of the ID or null */ protected function retrieveID(Model $model, ArgumentSet $args) { 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..d32dab7487 100644 --- a/src/lighthouse.php +++ b/src/lighthouse.php @@ -402,6 +402,19 @@ 'shortcut_foreign_key_selection' => false, + /* + |-------------------------------------------------------------------------- + | Nested Mutations Scope To Parent + |-------------------------------------------------------------------------- + | + | If set to true, the nested operations delete, disconnect, update and upsert + | only act on models related to the parent, otherwise they throw an error. + | Will be removed with v7, which always scopes them. + | + */ + + '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..0e96d9120c 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,173 @@ 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::UNRELATED_MODEL); + + $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)); + } + + 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::UNRELATED_MODEL); + + $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::UNRELATED_MODEL); + + $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', + ], + ], + ], + ], + ]); + } } diff --git a/tests/Integration/Execution/MutationExecutor/BelongsToTest.php b/tests/Integration/Execution/MutationExecutor/BelongsToTest.php index 660a9f4575..467bbe4827 100644 --- a/tests/Integration/Execution/MutationExecutor/BelongsToTest.php +++ b/tests/Integration/Execution/MutationExecutor/BelongsToTest.php @@ -1312,4 +1312,40 @@ 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', + ], + ], + ], + ]); + } } diff --git a/tests/Integration/Execution/MutationExecutor/HasManyTest.php b/tests/Integration/Execution/MutationExecutor/HasManyTest.php index bf564e1de9..b7e252fd3a 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,254 @@ 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::UNRELATED_MODEL); + + $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(ParentRelationScope::UNRELATED_MODEL); + + $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)); + } + + 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::UNRELATED_MODEL); + + $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::UNRELATED_MODEL); + + $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::UNRELATED_MODEL); + + $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', + ], + ], + ], + ], + ]); + } } diff --git a/tests/Integration/Execution/MutationExecutor/HasOneTest.php b/tests/Integration/Execution/MutationExecutor/HasOneTest.php index b6856e22c7..4248969f9e 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,94 @@ 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::UNRELATED_MODEL); + + $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::UNRELATED_MODEL); + + $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::UNRELATED_MODEL); + + $otherTasksPost->refresh(); + $this->assertSame('original', $otherTasksPost->title); + $this->assertSame($otherTask->id, $otherTasksPost->task_id); + } } diff --git a/tests/Integration/Execution/MutationExecutor/MorphManyTest.php b/tests/Integration/Execution/MutationExecutor/MorphManyTest.php index 497cd79de4..341ce93cc6 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::UNRELATED_MODEL); + + $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::UNRELATED_MODEL); + + $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..cc5362b9c8 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::UNRELATED_MODEL); + + $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..194e7cae9c 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::UNRELATED_MODEL); + + $otherTasksTag->refresh(); + + $this->assertSame('original', $otherTasksTag->name); + $this->assertCount(0, $task->tags()->get()); + } } diff --git a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php index e7cdb9feeb..e82f82c02f 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,58 @@ 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::UNRELATED_MODEL); + + $otherUsersTask->refresh(); + $this->assertSame(1, $otherUsersTask->difficulty); + $this->assertSame($otherUser->id, $otherUsersTask->user_id); + } } From 538cafb204910319c1fcc363dbbf1304d518e347 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 13 Oct 2026 20:20:44 +0200 Subject: [PATCH 02/11] Assert persisted state in scoped BelongsTo update test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- .../Execution/MutationExecutor/BelongsToTest.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/Integration/Execution/MutationExecutor/BelongsToTest.php b/tests/Integration/Execution/MutationExecutor/BelongsToTest.php index 467bbe4827..70fcd31461 100644 --- a/tests/Integration/Execution/MutationExecutor/BelongsToTest.php +++ b/tests/Integration/Execution/MutationExecutor/BelongsToTest.php @@ -1347,5 +1347,11 @@ public function testUpdateBelongsToAttachesUnrelatedModelWhenScopedToParent(): v ], ], ]); + + $task->refresh(); + $this->assertSame($otherUser->id, $task->user_id); + + $otherUser->refresh(); + $this->assertSame('updated', $otherUser->name); } } From 89ed4e3f960d4f3fa23db3a2674619a35375332e Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 25 Oct 2026 13:08:46 +0100 Subject: [PATCH 03/11] Accept a single ID in nested HasMany and BelongsToMany delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `destroy()` and `detach()` accepted a scalar, so `delete: ID` worked before. 🤖 Generated with Claude Code --- src/Execution/Arguments/NestedManyToMany.php | 4 +- src/Execution/Arguments/NestedOneToMany.php | 3 +- .../MutationExecutor/BelongsToManyTest.php | 68 ++++++++++++++++++ .../MutationExecutor/HasManyTest.php | 69 +++++++++++++++++++ 4 files changed, 140 insertions(+), 4 deletions(-) diff --git a/src/Execution/Arguments/NestedManyToMany.php b/src/Execution/Arguments/NestedManyToMany.php index 267ced984b..56a1b53e4a 100644 --- a/src/Execution/Arguments/NestedManyToMany.php +++ b/src/Execution/Arguments/NestedManyToMany.php @@ -61,9 +61,7 @@ public function __invoke($model, $args): void } if ($args->has('delete')) { - $ids = $args->arguments['delete']->toPlain(); - - ParentRelationScope::delete($relation, $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 f9916316d8..7958c61541 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 @@ -77,7 +78,7 @@ public function __invoke($model, $args): void } if ($args->has('delete')) { - ParentRelationScope::delete($relation, $args->arguments['delete']->toPlain()); + ParentRelationScope::delete($relation, Arr::wrap($args->arguments['delete']->toPlain())); } } } diff --git a/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php b/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php index 0e96d9120c..6ec46263c2 100644 --- a/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php +++ b/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php @@ -1165,6 +1165,74 @@ public function testDeleteBelongsToManyWhenScopedToParent(): void $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]); diff --git a/tests/Integration/Execution/MutationExecutor/HasManyTest.php b/tests/Integration/Execution/MutationExecutor/HasManyTest.php index b7e252fd3a..d4c1e2f139 100644 --- a/tests/Integration/Execution/MutationExecutor/HasManyTest.php +++ b/tests/Integration/Execution/MutationExecutor/HasManyTest.php @@ -980,6 +980,75 @@ public function testDeleteHasManyWhenScopedToParent(): void $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]); From cd09e68dd889934b7a3e93044c381fbaeeee7035 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 25 Oct 2026 13:09:00 +0100 Subject: [PATCH 04/11] Document scoped upsert through identifyingColumns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- docs/6/eloquent/nested-mutations.md | 1 + docs/master/eloquent/nested-mutations.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/6/eloquent/nested-mutations.md b/docs/6/eloquent/nested-mutations.md index a9b30ef66f..a76d9f142e 100644 --- a/docs/6/eloquent/nested-mutations.md +++ b/docs/6/eloquent/nested-mutations.md @@ -54,6 +54,7 @@ Set `nested_mutations_scope_to_parent` to `true` in `config/lighthouse.php` to l - 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 they relate models that are not yet related. Validate or authorize the IDs passed to them yourself. diff --git a/docs/master/eloquent/nested-mutations.md b/docs/master/eloquent/nested-mutations.md index a9b30ef66f..a76d9f142e 100644 --- a/docs/master/eloquent/nested-mutations.md +++ b/docs/master/eloquent/nested-mutations.md @@ -54,6 +54,7 @@ Set `nested_mutations_scope_to_parent` to `true` in `config/lighthouse.php` to l - 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 they relate models that are not yet related. Validate or authorize the IDs passed to them yourself. From 4e0ae5ba4dbb0bb8eca118586593575ce28ed184 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 25 Oct 2026 13:11:41 +0100 Subject: [PATCH 05/11] Let ParentRelationScope lookups choose between scoped and unscoped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Callers no longer branch on applies(), and unscoped lookups run the same queries as before the option existed. 🤖 Generated with Claude Code --- src/Execution/Arguments/NestedOneToMany.php | 11 +-- .../Arguments/ParentRelationScope.php | 67 +++++++++++++++---- src/Execution/Arguments/UpdateModel.php | 4 +- src/Execution/Arguments/UpsertModel.php | 14 +--- 4 files changed, 57 insertions(+), 39 deletions(-) diff --git a/src/Execution/Arguments/NestedOneToMany.php b/src/Execution/Arguments/NestedOneToMany.php index 7958c61541..35b91061d7 100644 --- a/src/Execution/Arguments/NestedOneToMany.php +++ b/src/Execution/Arguments/NestedOneToMany.php @@ -61,16 +61,7 @@ public function __invoke($model, $args): void } if ($args->has('disconnect')) { - $ids = $args->arguments['disconnect']->value; - $children = ParentRelationScope::applies($relation) - ? ParentRelationScope::findManyOrFail($relation, $ids) - : $relation - ->make() - ->whereIn( - $relation->make()->getKeyName(), - $ids, - ) - ->get(); + $children = ParentRelationScope::findMany($relation, $args->arguments['disconnect']->value); foreach ($children as $child) { $child->setAttribute($relation->getForeignKeyName(), null); $child->save(); diff --git a/src/Execution/Arguments/ParentRelationScope.php b/src/Execution/Arguments/ParentRelationScope.php index a215cce274..d279e36ba3 100644 --- a/src/Execution/Arguments/ParentRelationScope.php +++ b/src/Execution/Arguments/ParentRelationScope.php @@ -16,23 +16,45 @@ class ParentRelationScope /** * BelongsTo is excluded because its nested update and upsert attach unrelated models by design. * - * @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model> $relation + * @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null $relation */ - public static function applies(Relation $relation): bool + public static function applies(?Relation $relation): bool { // TODO remove the config check with v7 https://github.com/nuwave/lighthouse/issues/2744 - return ! $relation instanceof BelongsTo + return $relation !== null + && ! $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); + } + + assert($relation !== null, 'applies() is false for null.'); + + 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 findManyOrFail(Relation $relation, array $ids): EloquentCollection + 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(); if (array_diff($ids, $models->modelKeys()) !== []) { @@ -48,9 +70,7 @@ public static function findManyOrFail(Relation $relation, array $ids): EloquentC */ public static function delete(Relation $relation, array $ids): void { - $models = self::applies($relation) - ? self::findManyOrFail($relation, $ids) - : $relation->getRelated()->newQuery()->whereKey($ids)->get(); + $models = self::findMany($relation, $ids); if ($relation instanceof BelongsToMany) { $relation->detach($ids); @@ -64,24 +84,43 @@ public static function delete(Relation $relation, array $ids): void /** * Returns null if no model matches anywhere, so upsert can create one. * - * @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model> $relation + * @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]); + } + + /** + * Returns null if no model matches anywhere, so upsert can create one. + * + * @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null $relation * @param array $columns */ - public static function firstForUpsert(Relation $relation, array $columns): ?Model + public static function firstForUpsert(?Relation $relation, Model $model, array $columns): ?Model { - $related = $relation->getRelated(); + if (! self::applies($relation)) { + return $model->newQuery()->firstWhere($columns); + } + + assert($relation !== null, 'applies() is false for null.'); $qualifiedColumns = []; foreach ($columns as $column => $value) { - $qualifiedColumns[$related->qualifyColumn($column)] = $value; + $qualifiedColumns[$model->qualifyColumn($column)] = $value; } - $model = (clone $relation)->where($qualifiedColumns)->first(); + $scopedModel = (clone $relation)->where($qualifiedColumns)->first(); - if ($model === null && $related->newQuery()->where($columns)->exists()) { + if ($scopedModel === null && $model->newQuery()->where($columns)->exists()) { throw new Error(self::UNRELATED_MODEL); } - return $model; + return $scopedModel; } } diff --git a/src/Execution/Arguments/UpdateModel.php b/src/Execution/Arguments/UpdateModel.php index c3a2cae2d1..912a8302c2 100644 --- a/src/Execution/Arguments/UpdateModel.php +++ b/src/Execution/Arguments/UpdateModel.php @@ -37,9 +37,7 @@ public function __invoke($model, $args): mixed ?? throw new Error(self::MISSING_PRIMARY_KEY_FOR_UPDATE); assert($id instanceof Argument); - $model = $this->parentRelation !== null && ParentRelationScope::applies($this->parentRelation) - ? ParentRelationScope::findManyOrFail($this->parentRelation, [$id->value])->firstOrFail() - : $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 75e90baeea..501d1c5a3e 100644 --- a/src/Execution/Arguments/UpsertModel.php +++ b/src/Execution/Arguments/UpsertModel.php @@ -43,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 = $this->findExisting($model, $identifyingColumns); + $existingModel = ParentRelationScope::firstForUpsert($this->parentRelation, $model, $identifyingColumns); if ($existingModel !== null) { $model = $existingModel; @@ -53,7 +53,7 @@ public function __invoke($model, $args): mixed if ($existingModel === null) { $id = $this->retrieveID($model, $args); if ($id) { - $existingModel = $this->findExisting($model, [$model->getKeyName() => $id]); + $existingModel = ParentRelationScope::findForUpsert($this->parentRelation, $model, $id); if ($existingModel !== null) { $model = $existingModel; } @@ -88,16 +88,6 @@ protected function identifyingColumnValues(ArgumentSet $args, array $identifying return $identifyingValues; } - /** @param array $columns */ - protected function findExisting(Model $model, array $columns): ?Model - { - if ($this->parentRelation !== null && ParentRelationScope::applies($this->parentRelation)) { - return ParentRelationScope::firstForUpsert($this->parentRelation, $columns); - } - - return $model->newQuery()->firstWhere($columns); - } - /** @return mixed The value of the ID or null */ protected function retrieveID(Model $model, ArgumentSet $args) { From fe8b57416c1a889a62143f78915f6599787a2a74 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 25 Oct 2026 13:21:42 +0100 Subject: [PATCH 06/11] Scope nested @delete argument resolver to the parent relation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With `nested_mutations_scope_to_parent`, `@delete(relation: ...)` deleted models of other parents. 🤖 Generated with Claude Code --- src/Schema/Directives/DeleteDirective.php | 7 +- .../Schema/Directives/DeleteDirectiveTest.php | 121 ++++++++++++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/src/Schema/Directives/DeleteDirective.php b/src/Schema/Directives/DeleteDirective.php index c12e05fe36..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(); // @phpstan-ignore method.notFound (Relation delegates to Builder) - $related::destroy($idOrIds); + foreach (ParentRelationScope::findMany($relation, Arr::wrap($idOrIds)) as $related) { + $related->delete(); + } } } diff --git a/tests/Integration/Schema/Directives/DeleteDirectiveTest.php b/tests/Integration/Schema/Directives/DeleteDirectiveTest.php index 9d7d8e2363..cd4939ea4e 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::UNRELATED_MODEL); + + $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::UNRELATED_MODEL); + + $this->assertNotNull(Role::find($otherUsersRole->id)); + $this->assertCount(1, $otherUsersRole->users()->get()); + } + public function testDeleteHasOneThroughNestedArgResolver(): void { $task = factory(Task::class)->create(); From cc9c9e77eca9ef27bc207b41c8616327594a1784 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 25 Oct 2026 13:23:56 +0100 Subject: [PATCH 07/11] Cover scoped nested mutations on related models and directive resolvers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- .../MutationExecutor/BelongsToManyTest.php | 69 +++++++++ .../MutationExecutor/HasManyTest.php | 145 ++++++++++++++++++ .../Execution/MutationExecutor/HasOneTest.php | 36 +++++ .../Schema/Directives/UpdateDirectiveTest.php | 49 ++++++ .../Directives/UpdateManyDirectiveTest.php | 51 ++++++ .../Schema/Directives/UpsertDirectiveTest.php | 129 ++++++++++++++++ .../Directives/UpsertManyDirectiveTest.php | 49 ++++++ 7 files changed, 528 insertions(+) diff --git a/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php b/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php index 6ec46263c2..d08be5fe28 100644 --- a/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php +++ b/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php @@ -1337,4 +1337,73 @@ public function testUpdateBelongsToManyWhenScopedToParent(): void ], ]); } + + 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::UNRELATED_MODEL); + + $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/HasManyTest.php b/tests/Integration/Execution/MutationExecutor/HasManyTest.php index d4c1e2f139..bc489d2b31 100644 --- a/tests/Integration/Execution/MutationExecutor/HasManyTest.php +++ b/tests/Integration/Execution/MutationExecutor/HasManyTest.php @@ -1208,4 +1208,149 @@ public function testUpsertHasManyWithMissingIDCreatesWhenScopedToParent(): void ], ]); } + + 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::UNRELATED_MODEL); + + $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 4248969f9e..0354583440 100644 --- a/tests/Integration/Execution/MutationExecutor/HasOneTest.php +++ b/tests/Integration/Execution/MutationExecutor/HasOneTest.php @@ -637,4 +637,40 @@ public function testUpsertHasOneWithoutRelationFailsWhenScopedToParent(): void $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/Schema/Directives/UpdateDirectiveTest.php b/tests/Integration/Schema/Directives/UpdateDirectiveTest.php index fb0839dc88..723f3841f2 100644 --- a/tests/Integration/Schema/Directives/UpdateDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpdateDirectiveTest.php @@ -4,6 +4,7 @@ use Illuminate\Database\QueryException; use Nuwave\Lighthouse\Execution\Arguments\UpdateModel; +use Nuwave\Lighthouse\Execution\Arguments\ParentRelationScope; use Tests\DBTestCase; use Tests\Utils\Models\Category; use Tests\Utils\Models\Company; @@ -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::UNRELATED_MODEL); + + $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..ef3427aba6 100644 --- a/tests/Integration/Schema/Directives/UpdateManyDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpdateManyDirectiveTest.php @@ -5,6 +5,9 @@ use Illuminate\Database\Eloquent\ModelNotFoundException; use Nuwave\Lighthouse\Exceptions\DefinitionException; use Nuwave\Lighthouse\Schema\Directives\ManyModelMutationDirective; +use Nuwave\Lighthouse\Execution\Arguments\ParentRelationScope; +use Tests\Utils\Models\Task; +use Tests\Utils\Models\User; use Tests\DBTestCase; use Tests\Utils\Models\Company; @@ -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::UNRELATED_MODEL); + + $otherUsersTask->refresh(); + $this->assertSame('original', $otherUsersTask->name); + } } diff --git a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php index e82f82c02f..668380db84 100644 --- a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php @@ -715,4 +715,133 @@ public function testNestedUpsertByIdentifyingColumnWithoutRelationFailsWhenScope $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..adadc9f394 100644 --- a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php @@ -7,6 +7,7 @@ use Nuwave\Lighthouse\Exceptions\DefinitionException; use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use Nuwave\Lighthouse\Schema\TypeRegistry; +use Nuwave\Lighthouse\Execution\Arguments\ParentRelationScope; use Tests\DBTestCase; use Tests\Utils\Models\Task; use Tests\Utils\Models\User; @@ -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::UNRELATED_MODEL); + + $otherUsersTask->refresh(); + $this->assertSame('original', $otherUsersTask->name); + } } From 0488f121931d5430c37d193106436d2c657ed365 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 25 Oct 2026 13:26:45 +0100 Subject: [PATCH 08/11] Tighten wording and annotations around parent relation scoping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- UPGRADE.md | 3 ++- docs/6/eloquent/nested-mutations.md | 2 +- docs/master/eloquent/nested-mutations.md | 2 +- src/Execution/Arguments/ParentRelationScope.php | 15 ++++----------- src/lighthouse.php | 5 ++--- 5 files changed, 10 insertions(+), 17 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index b81180760e..35aeb18ce8 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -11,7 +11,8 @@ Compare your `lighthouse.php` against the latest [default configuration](src/lig ### Nested mutations only act on related models -The config option `nested_mutations_scope_to_parent` was removed, its behaviour now always applies. +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`. diff --git a/docs/6/eloquent/nested-mutations.md b/docs/6/eloquent/nested-mutations.md index a76d9f142e..ac99beaa42 100644 --- a/docs/6/eloquent/nested-mutations.md +++ b/docs/6/eloquent/nested-mutations.md @@ -56,7 +56,7 @@ Set `nested_mutations_scope_to_parent` to `true` in `config/lighthouse.php` to l - `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 they relate models that are not yet related. +`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 diff --git a/docs/master/eloquent/nested-mutations.md b/docs/master/eloquent/nested-mutations.md index a76d9f142e..ac99beaa42 100644 --- a/docs/master/eloquent/nested-mutations.md +++ b/docs/master/eloquent/nested-mutations.md @@ -56,7 +56,7 @@ Set `nested_mutations_scope_to_parent` to `true` in `config/lighthouse.php` to l - `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 they relate models that are not yet related. +`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 diff --git a/src/Execution/Arguments/ParentRelationScope.php b/src/Execution/Arguments/ParentRelationScope.php index d279e36ba3..70138a19ac 100644 --- a/src/Execution/Arguments/ParentRelationScope.php +++ b/src/Execution/Arguments/ParentRelationScope.php @@ -14,14 +14,15 @@ class ParentRelationScope public const UNRELATED_MODEL = 'Cannot modify a model that is not related to the given parent.'; /** - * BelongsTo is excluded because its nested update and upsert attach unrelated models by design. - * * @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 https://github.com/nuwave/lighthouse/issues/2744 + // 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'); } @@ -36,8 +37,6 @@ public static function findOrFail(?Relation $relation, Model $model, mixed $id): return $model->newQuery()->findOrFail($id); } - assert($relation !== null, 'applies() is false for null.'); - return self::findMany($relation, [$id])->firstOrFail(); } @@ -82,8 +81,6 @@ public static function delete(Relation $relation, array $ids): void } /** - * Returns null if no model matches anywhere, so upsert can create one. - * * @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null $relation * @param int|string $id */ @@ -97,8 +94,6 @@ public static function findForUpsert(?Relation $relation, Model $model, mixed $i } /** - * Returns null if no model matches anywhere, so upsert can create one. - * * @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null $relation * @param array $columns */ @@ -108,8 +103,6 @@ public static function firstForUpsert(?Relation $relation, Model $model, array $ return $model->newQuery()->firstWhere($columns); } - assert($relation !== null, 'applies() is false for null.'); - $qualifiedColumns = []; foreach ($columns as $column => $value) { $qualifiedColumns[$model->qualifyColumn($column)] = $value; diff --git a/src/lighthouse.php b/src/lighthouse.php index d32dab7487..0f336d56e9 100644 --- a/src/lighthouse.php +++ b/src/lighthouse.php @@ -407,9 +407,8 @@ | Nested Mutations Scope To Parent |-------------------------------------------------------------------------- | - | If set to true, the nested operations delete, disconnect, update and upsert - | only act on models related to the parent, otherwise they throw an error. - | Will be removed with v7, which always scopes them. + | 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. | */ From dd31186a6f12cfe05c6dd59e81be2d3c365b5663 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 25 Oct 2026 13:28:52 +0100 Subject: [PATCH 09/11] Name the unrelated IDs in the parent relation scope error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- src/Execution/Arguments/ParentRelationScope.php | 17 +++++++++++++---- .../MutationExecutor/BelongsToManyTest.php | 8 ++++---- .../Execution/MutationExecutor/HasManyTest.php | 12 ++++++------ .../Execution/MutationExecutor/HasOneTest.php | 6 +++--- .../MutationExecutor/MorphManyTest.php | 4 ++-- .../Execution/MutationExecutor/MorphOneTest.php | 2 +- .../MutationExecutor/MorphToManyTest.php | 2 +- .../Schema/Directives/DeleteDirectiveTest.php | 4 ++-- .../Schema/Directives/UpdateDirectiveTest.php | 2 +- .../Directives/UpdateManyDirectiveTest.php | 2 +- .../Schema/Directives/UpsertDirectiveTest.php | 2 +- .../Directives/UpsertManyDirectiveTest.php | 2 +- 12 files changed, 36 insertions(+), 27 deletions(-) diff --git a/src/Execution/Arguments/ParentRelationScope.php b/src/Execution/Arguments/ParentRelationScope.php index 70138a19ac..e7863ff0f1 100644 --- a/src/Execution/Arguments/ParentRelationScope.php +++ b/src/Execution/Arguments/ParentRelationScope.php @@ -11,7 +11,15 @@ class ParentRelationScope { - public const UNRELATED_MODEL = 'Cannot modify a model that is not related to the given parent.'; + public const UNRELATED_MODEL = 'Cannot modify a model that is not related to the given parent'; + + /** @param array $values */ + public static function unrelatedModel(array $values): string + { + $valueList = implode(', ', $values); + + return self::UNRELATED_MODEL . ": {$valueList}."; + } /** * @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null $relation @@ -56,8 +64,9 @@ public static function findMany(Relation $relation, array $ids): EloquentCollect $models = (clone $relation)->whereKey($ids)->get(); - if (array_diff($ids, $models->modelKeys()) !== []) { - throw new Error(self::UNRELATED_MODEL); + $unrelatedIDs = array_diff($ids, $models->modelKeys()); + if ($unrelatedIDs !== []) { + throw new Error(self::unrelatedModel($unrelatedIDs)); } return $models; @@ -111,7 +120,7 @@ public static function firstForUpsert(?Relation $relation, Model $model, array $ $scopedModel = (clone $relation)->where($qualifiedColumns)->first(); if ($scopedModel === null && $model->newQuery()->where($columns)->exists()) { - throw new Error(self::UNRELATED_MODEL); + throw new Error(self::unrelatedModel($columns)); } return $scopedModel; diff --git a/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php b/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php index d08be5fe28..b051cc9b1f 100644 --- a/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php +++ b/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php @@ -1124,7 +1124,7 @@ public function testDeleteBelongsToManyWithoutRelationFailsWhenScopedToParent(): GRAPHQL, [ 'roleID' => $role->id, 'userID' => $otherRolesUser->id, - ])->assertGraphQLErrorMessage(ParentRelationScope::UNRELATED_MODEL); + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherRolesUser->id])); $this->assertNotNull(User::find($otherRolesUser->id)); $this->assertCount(1, $otherRole->users()->get()); @@ -1258,7 +1258,7 @@ public function testUpdateBelongsToManyWithoutRelationFailsWhenScopedToParent(): GRAPHQL, [ 'roleID' => $role->id, 'userID' => $otherRolesUser->id, - ])->assertGraphQLErrorMessage(ParentRelationScope::UNRELATED_MODEL); + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherRolesUser->id])); $otherRolesUser->refresh(); @@ -1291,7 +1291,7 @@ public function testUpsertBelongsToManyWithoutRelationFailsWhenScopedToParent(): GRAPHQL, [ 'roleID' => $role->id, 'userID' => $otherRolesUser->id, - ])->assertGraphQLErrorMessage(ParentRelationScope::UNRELATED_MODEL); + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherRolesUser->id])); $otherRolesUser->refresh(); @@ -1361,7 +1361,7 @@ public function testDeleteBelongsToManyWithOwnAndForeignIDsFailsWhenScopedToPare GRAPHQL, [ 'roleID' => $role->id, 'userIDs' => [$user->id, $otherUser->id], - ])->assertGraphQLErrorMessage(ParentRelationScope::UNRELATED_MODEL); + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherUser->id])); $this->assertNotNull(User::find($user->id)); $this->assertNotNull(User::find($otherUser->id)); diff --git a/tests/Integration/Execution/MutationExecutor/HasManyTest.php b/tests/Integration/Execution/MutationExecutor/HasManyTest.php index bc489d2b31..9474347767 100644 --- a/tests/Integration/Execution/MutationExecutor/HasManyTest.php +++ b/tests/Integration/Execution/MutationExecutor/HasManyTest.php @@ -911,7 +911,7 @@ public function testDeleteHasManyWithoutRelationFailsWhenScopedToParent(): void GRAPHQL, [ 'userID' => $user->id, 'taskID' => $otherUsersTask->id, - ])->assertGraphQLErrorMessage(ParentRelationScope::UNRELATED_MODEL); + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherUsersTask->id])); $this->assertNotNull(Task::find($otherUsersTask->id)); } @@ -939,7 +939,7 @@ public function testDeleteHasManyWithMissingIDFailsWhenScopedToParent(): void GRAPHQL, [ 'userID' => $user->id, 'taskID' => $task->id, - ])->assertGraphQLErrorMessage(ParentRelationScope::UNRELATED_MODEL); + ])->assertGraphQLErrorMessage('Cannot modify a model that is not related to the given parent: 999.'); $this->assertNotNull(Task::find($task->id)); } @@ -1073,7 +1073,7 @@ public function testDisconnectHasManyWithoutRelationFailsWhenScopedToParent(): v GRAPHQL, [ 'userID' => $user->id, 'taskID' => $otherUsersTask->id, - ])->assertGraphQLErrorMessage(ParentRelationScope::UNRELATED_MODEL); + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherUsersTask->id])); $otherUsersTask->refresh(); @@ -1134,7 +1134,7 @@ public function testUpdateHasManyWithoutRelationFailsWhenScopedToParent(): void GRAPHQL, [ 'userID' => $user->id, 'taskID' => $otherUsersTask->id, - ])->assertGraphQLErrorMessage(ParentRelationScope::UNRELATED_MODEL); + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherUsersTask->id])); $otherUsersTask->refresh(); $this->assertSame('original', $otherUsersTask->name); @@ -1166,7 +1166,7 @@ public function testUpsertHasManyWithoutRelationFailsWhenScopedToParent(): void GRAPHQL, [ 'userID' => $user->id, 'taskID' => $otherUsersTask->id, - ])->assertGraphQLErrorMessage(ParentRelationScope::UNRELATED_MODEL); + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherUsersTask->id])); $otherUsersTask->refresh(); $this->assertSame('original', $otherUsersTask->name); @@ -1348,7 +1348,7 @@ public function testDisconnectHasManyWithOwnAndForeignIDsFailsWhenScopedToParent GRAPHQL, [ 'userID' => $user->id, 'taskIDs' => [$task->id, $otherUsersTask->id], - ])->assertGraphQLErrorMessage(ParentRelationScope::UNRELATED_MODEL); + ])->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 0354583440..43b2b29470 100644 --- a/tests/Integration/Execution/MutationExecutor/HasOneTest.php +++ b/tests/Integration/Execution/MutationExecutor/HasOneTest.php @@ -569,7 +569,7 @@ public function testDeleteHasOneWithoutRelationFailsWhenScopedToParent(): void GRAPHQL, [ 'taskID' => $task->id, 'postID' => $otherTasksPost->id, - ])->assertGraphQLErrorMessage(ParentRelationScope::UNRELATED_MODEL); + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherTasksPost->id])); $this->assertNotNull(Post::find($otherTasksPost->id)); } @@ -599,7 +599,7 @@ public function testUpdateHasOneWithoutRelationFailsWhenScopedToParent(): void GRAPHQL, [ 'taskID' => $task->id, 'postID' => $otherTasksPost->id, - ])->assertGraphQLErrorMessage(ParentRelationScope::UNRELATED_MODEL); + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherTasksPost->id])); $otherTasksPost->refresh(); $this->assertSame('original', $otherTasksPost->title); @@ -631,7 +631,7 @@ public function testUpsertHasOneWithoutRelationFailsWhenScopedToParent(): void GRAPHQL, [ 'taskID' => $task->id, 'postID' => $otherTasksPost->id, - ])->assertGraphQLErrorMessage(ParentRelationScope::UNRELATED_MODEL); + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherTasksPost->id])); $otherTasksPost->refresh(); $this->assertSame('original', $otherTasksPost->title); diff --git a/tests/Integration/Execution/MutationExecutor/MorphManyTest.php b/tests/Integration/Execution/MutationExecutor/MorphManyTest.php index 341ce93cc6..3d65f0dda6 100644 --- a/tests/Integration/Execution/MutationExecutor/MorphManyTest.php +++ b/tests/Integration/Execution/MutationExecutor/MorphManyTest.php @@ -610,7 +610,7 @@ public function testDeleteMorphManyWithoutRelationFailsWhenScopedToParent(): voi GRAPHQL, [ 'taskID' => $task->id, 'imageID' => $otherTasksImage->id, - ])->assertGraphQLErrorMessage(ParentRelationScope::UNRELATED_MODEL); + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherTasksImage->id])); $this->assertNotNull(Image::find($otherTasksImage->id)); } @@ -640,7 +640,7 @@ public function testUpsertMorphManyWithoutRelationFailsWhenScopedToParent(): voi GRAPHQL, [ 'taskID' => $task->id, 'imageID' => $otherTasksImage->id, - ])->assertGraphQLErrorMessage(ParentRelationScope::UNRELATED_MODEL); + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherTasksImage->id])); $otherTasksImage->refresh(); $this->assertSame('original', $otherTasksImage->url); diff --git a/tests/Integration/Execution/MutationExecutor/MorphOneTest.php b/tests/Integration/Execution/MutationExecutor/MorphOneTest.php index cc5362b9c8..d29229cb5b 100644 --- a/tests/Integration/Execution/MutationExecutor/MorphOneTest.php +++ b/tests/Integration/Execution/MutationExecutor/MorphOneTest.php @@ -442,7 +442,7 @@ public function testUpsertMorphOneWithoutRelationFailsWhenScopedToParent(): void GRAPHQL, [ 'taskID' => $task->id, 'imageID' => $otherTasksImage->id, - ])->assertGraphQLErrorMessage(ParentRelationScope::UNRELATED_MODEL); + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherTasksImage->id])); $otherTasksImage->refresh(); $this->assertSame('original', $otherTasksImage->url); diff --git a/tests/Integration/Execution/MutationExecutor/MorphToManyTest.php b/tests/Integration/Execution/MutationExecutor/MorphToManyTest.php index 194e7cae9c..78ddbfb66d 100644 --- a/tests/Integration/Execution/MutationExecutor/MorphToManyTest.php +++ b/tests/Integration/Execution/MutationExecutor/MorphToManyTest.php @@ -391,7 +391,7 @@ public function testUpsertMorphToManyWithoutRelationFailsWhenScopedToParent(): v GRAPHQL, [ 'taskID' => $task->id, 'tagID' => $otherTasksTag->id, - ])->assertGraphQLErrorMessage(ParentRelationScope::UNRELATED_MODEL); + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherTasksTag->id])); $otherTasksTag->refresh(); diff --git a/tests/Integration/Schema/Directives/DeleteDirectiveTest.php b/tests/Integration/Schema/Directives/DeleteDirectiveTest.php index cd4939ea4e..4f761f7636 100644 --- a/tests/Integration/Schema/Directives/DeleteDirectiveTest.php +++ b/tests/Integration/Schema/Directives/DeleteDirectiveTest.php @@ -476,7 +476,7 @@ public function testNestedArgResolverDeleteWithoutRelationFailsWhenScopedToParen GRAPHQL, [ 'id' => $user->id, 'deleteTasks' => [$otherUsersTask->id], - ])->assertGraphQLErrorMessage(ParentRelationScope::UNRELATED_MODEL); + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherUsersTask->id])); $this->assertNotNull(Task::find($otherUsersTask->id)); } @@ -511,7 +511,7 @@ public function testNestedArgResolverDeleteBelongsToManyWithoutRelationFailsWhen GRAPHQL, [ 'id' => $user->id, 'deleteRoles' => [$otherUsersRole->id], - ])->assertGraphQLErrorMessage(ParentRelationScope::UNRELATED_MODEL); + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherUsersRole->id])); $this->assertNotNull(Role::find($otherUsersRole->id)); $this->assertCount(1, $otherUsersRole->users()->get()); diff --git a/tests/Integration/Schema/Directives/UpdateDirectiveTest.php b/tests/Integration/Schema/Directives/UpdateDirectiveTest.php index 723f3841f2..1eb323546d 100644 --- a/tests/Integration/Schema/Directives/UpdateDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpdateDirectiveTest.php @@ -631,7 +631,7 @@ public function testNestedUpdateWithoutRelationFailsWhenScopedToParent(): void GRAPHQL, [ 'userID' => $user->id, 'taskID' => $otherUsersTask->id, - ])->assertGraphQLErrorMessage(ParentRelationScope::UNRELATED_MODEL); + ])->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 ef3427aba6..75eb24b6f8 100644 --- a/tests/Integration/Schema/Directives/UpdateManyDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpdateManyDirectiveTest.php @@ -373,7 +373,7 @@ public function testNestedUpdateManyWithoutRelationFailsWhenScopedToParent(): vo GRAPHQL, [ 'userID' => $user->id, 'taskID' => $otherUsersTask->id, - ])->assertGraphQLErrorMessage(ParentRelationScope::UNRELATED_MODEL); + ])->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 668380db84..282f900230 100644 --- a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php @@ -709,7 +709,7 @@ public function testNestedUpsertByIdentifyingColumnWithoutRelationFailsWhenScope } GRAPHQL, [ 'userID' => $user->id, - ])->assertGraphQLErrorMessage(ParentRelationScope::UNRELATED_MODEL); + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel(['existing-task'])); $otherUsersTask->refresh(); $this->assertSame(1, $otherUsersTask->difficulty); diff --git a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php index adadc9f394..b500f6634b 100644 --- a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php @@ -579,7 +579,7 @@ public function testNestedUpsertManyWithoutRelationFailsWhenScopedToParent(): vo GRAPHQL, [ 'userID' => $user->id, 'taskID' => $otherUsersTask->id, - ])->assertGraphQLErrorMessage(ParentRelationScope::UNRELATED_MODEL); + ])->assertGraphQLErrorMessage(ParentRelationScope::unrelatedModel([$otherUsersTask->id])); $otherUsersTask->refresh(); $this->assertSame('original', $otherUsersTask->name); From df845f3a16fe67b237e721cd3eb10754387b7976 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 25 Oct 2026 13:30:23 +0100 Subject: [PATCH 10/11] Encode non-scalar values in the parent relation scope error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An array identifying value crashed with "Array to string conversion". 🤖 Generated with Claude Code --- src/Execution/Arguments/ParentRelationScope.php | 7 ++++++- .../Arguments/ParentRelationScopeTest.php | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Execution/Arguments/ParentRelationScopeTest.php diff --git a/src/Execution/Arguments/ParentRelationScope.php b/src/Execution/Arguments/ParentRelationScope.php index e7863ff0f1..04900e3d39 100644 --- a/src/Execution/Arguments/ParentRelationScope.php +++ b/src/Execution/Arguments/ParentRelationScope.php @@ -9,6 +9,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\Relation; +use function Safe\json_encode; + class ParentRelationScope { public const UNRELATED_MODEL = 'Cannot modify a model that is not related to the given parent'; @@ -16,7 +18,10 @@ class ParentRelationScope /** @param array $values */ public static function unrelatedModel(array $values): string { - $valueList = implode(', ', $values); + $valueList = implode(', ', array_map( + static fn (mixed $value): mixed => is_scalar($value) ? $value : json_encode($value), + $values, + )); return self::UNRELATED_MODEL . ": {$valueList}."; } 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]]), + ); + } +} From 6985af831452e9023c25892835a8c70e1d87f743 Mon Sep 17 00:00:00 2001 From: spawnia Date: Sun, 25 Oct 2026 12:31:20 +0000 Subject: [PATCH 11/11] Apply php-cs-fixer changes --- tests/Integration/Schema/Directives/UpdateDirectiveTest.php | 2 +- .../Schema/Directives/UpdateManyDirectiveTest.php | 6 +++--- .../Schema/Directives/UpsertManyDirectiveTest.php | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Integration/Schema/Directives/UpdateDirectiveTest.php b/tests/Integration/Schema/Directives/UpdateDirectiveTest.php index 1eb323546d..efe760fe8f 100644 --- a/tests/Integration/Schema/Directives/UpdateDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpdateDirectiveTest.php @@ -3,8 +3,8 @@ namespace Tests\Integration\Schema\Directives; use Illuminate\Database\QueryException; -use Nuwave\Lighthouse\Execution\Arguments\UpdateModel; use Nuwave\Lighthouse\Execution\Arguments\ParentRelationScope; +use Nuwave\Lighthouse\Execution\Arguments\UpdateModel; use Tests\DBTestCase; use Tests\Utils\Models\Category; use Tests\Utils\Models\Company; diff --git a/tests/Integration/Schema/Directives/UpdateManyDirectiveTest.php b/tests/Integration/Schema/Directives/UpdateManyDirectiveTest.php index 75eb24b6f8..9db34d2d89 100644 --- a/tests/Integration/Schema/Directives/UpdateManyDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpdateManyDirectiveTest.php @@ -4,12 +4,12 @@ use Illuminate\Database\Eloquent\ModelNotFoundException; use Nuwave\Lighthouse\Exceptions\DefinitionException; -use Nuwave\Lighthouse\Schema\Directives\ManyModelMutationDirective; use Nuwave\Lighthouse\Execution\Arguments\ParentRelationScope; -use Tests\Utils\Models\Task; -use Tests\Utils\Models\User; +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 { diff --git a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php index b500f6634b..07a5b25930 100644 --- a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php @@ -5,9 +5,9 @@ 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 Nuwave\Lighthouse\Execution\Arguments\ParentRelationScope; use Tests\DBTestCase; use Tests\Utils\Models\Task; use Tests\Utils\Models\User;