From f7a82a9dc2b9a76e7f90e2b6d36ceca607a9132d Mon Sep 17 00:00:00 2001 From: GavG Date: Thu, 27 Jul 2023 17:42:46 +0100 Subject: [PATCH 01/31] WIP: adding identifyingColumns directive to upserts --- src/Execution/Arguments/UpsertModel.php | 35 ++++- src/Schema/Directives/UpsertDirective.php | 11 +- .../Schema/Directives/UpsertDirectiveTest.php | 144 ++++++++++++++++++ 3 files changed, 183 insertions(+), 7 deletions(-) diff --git a/src/Execution/Arguments/UpsertModel.php b/src/Execution/Arguments/UpsertModel.php index a54db03477..fc39ed46e7 100644 --- a/src/Execution/Arguments/UpsertModel.php +++ b/src/Execution/Arguments/UpsertModel.php @@ -9,10 +9,14 @@ class UpsertModel implements ArgResolver /** @var callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver */ protected $previous; + /** @var array */ + protected array $identifyingColumns; + /** @param callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver $previous */ - public function __construct(callable $previous) + public function __construct(callable $previous, ?array $identifyingColumns) { $this->previous = $previous; + $this->identifyingColumns = $identifyingColumns ?? []; } /** @@ -22,20 +26,39 @@ public function __construct(callable $previous) public function __invoke($model, $args): mixed { // TODO consider Laravel native ->upsert(), available from 8.10 - $id = $args->arguments['id'] - ?? $args->arguments[$model->getKeyName()] - ?? null; + $existingModel = null; - if ($id !== null) { + if (!empty($this->identifyingColumns)) { $existingModel = $model ->newQuery() - ->find($id->value); + ->firstWhere( + array_intersect_key( + $args->toArray(), + array_flip($this->identifyingColumns) + ) + ); if ($existingModel !== null) { $model = $existingModel; } } + if ($existingModel === null) { + $id = $args->arguments['id'] + ?? $args->arguments[$model->getKeyName()] + ?? null; + + if ($id !== null) { + $existingModel = $model + ->newQuery() + ->find($id->value); + + if ($existingModel !== null) { + $model = $existingModel; + } + } + } + return ($this->previous)($model, $args); } } diff --git a/src/Schema/Directives/UpsertDirective.php b/src/Schema/Directives/UpsertDirective.php index 6bd698b6c3..954983e429 100644 --- a/src/Schema/Directives/UpsertDirective.php +++ b/src/Schema/Directives/UpsertDirective.php @@ -21,6 +21,12 @@ public static function definition(): string """ model: String + """ + Specify the columns by which to upsert the model. + This is optional, defaults to the ID or model Key. + """ + identifyingColumns: [String!] = [] + """ Specify the name of the relation on the parent model. This is only needed when using this directive as a nested arg @@ -33,6 +39,9 @@ public static function definition(): string protected function makeExecutionFunction(Relation $parentRelation = null): callable { - return new UpsertModel(new SaveModel($parentRelation)); + return new UpsertModel( + new SaveModel($parentRelation), + $this->directiveArgValue('identifyingColumns') + ); } } diff --git a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php index b04bbe6a08..249ad2e81c 100644 --- a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php @@ -6,6 +6,7 @@ use Illuminate\Container\Container; use Nuwave\Lighthouse\Schema\TypeRegistry; use Tests\DBTestCase; +use Tests\Utils\Models\Company; use Tests\Utils\Models\Task; use Tests\Utils\Models\User; @@ -198,6 +199,149 @@ interface IUser ]); } + public function testDirectUpsertByIdentifyingColumn(): void + { + $this->schema .= /** @lang GraphQL */ ' + type User { + id: ID! + email: String! + name: String! + } + + type Mutation { + upsertUser(name: String!, email: String!): User @upsert(identifyingColumns: ["email"]) + } + '; + + $this->graphQL(/** @lang GraphQL */ ' + mutation { + upsertUser( + email: "foo@te.st" + name: "bar" + ) { + name + email + } + } + ')->assertJson([ + 'data' => [ + 'upsertUser' => [ + 'email' => "foo@te.st", + 'name' => "bar" + ], + ], + ]); + + $user = User::firstOrFail(); + + $this->assertSame('bar', $user->name); + $this->assertSame('foo@te.st', $user->email); + + $this->graphQL(/** @lang GraphQL */ ' + mutation { + upsertUser( + email: "foo@te.st" + name: "foo" + ) { + name + email + } + } + ')->assertJson([ + 'data' => [ + 'upsertUser' => [ + 'email' => "foo@te.st", + 'name' => "foo" + ], + ], + ]); + + $user->refresh(); + + $this->assertSame('foo', $user->name); + $this->assertSame('foo@te.st', $user->email); + } + + public function testDirectUpsertByIdentifyingColumns(): void + { + $company = factory(Company::class)->create(['id' => 1]); + + $this->schema .= + /** @lang GraphQL */ + ' + type User { + id: ID! + email: String! + name: String! + company_id: ID! + } + + type Mutation { + upsertUser(name: String!, email: String!, company_id:ID!): User @upsert(identifyingColumns: ["name", "company_id"]) + } + '; + + $this->graphQL( + /** @lang GraphQL */ + ' + mutation { + upsertUser( + email: "foo@te.st" + name: "bar" + company_id: 1 + ) { + name + email + company_id + } + } + ')->assertJson([ + 'data' => [ + 'upsertUser' => [ + 'email' => "foo@te.st", + 'name' => "bar", + 'company_id' => 1 + ], + ], + ]); + + $user = User::firstOrFail(); + + $this->assertSame('bar', $user->name); + $this->assertSame('foo@te.st', $user->email); + $this->assertSame(1, $user->company_id); + + $this->graphQL( + /** @lang GraphQL */ + ' + mutation { + upsertUser( + email: "bar@te.st" + name: "bar" + company_id: 1 + ) { + name + email + company_id + } + } + ' + )->assertJson([ + 'data' => [ + 'upsertUser' => [ + 'email' => "bar@te.st", + 'name' => "bar", + 'company_id' => $company->id + ], + ], + ]); + + $user->refresh(); + + $this->assertSame('bar', $user->name); + $this->assertSame('bar@te.st', $user->email); + } + public static function resolveType(): Type { $typeRegistry = Container::getInstance()->make(TypeRegistry::class); From 0e63ba6858943227e3b84b2565d76d0669055240 Mon Sep 17 00:00:00 2001 From: GavG Date: Thu, 27 Jul 2023 16:43:45 +0000 Subject: [PATCH 02/31] Apply php-cs-fixer changes --- src/Execution/Arguments/UpsertModel.php | 8 ++--- src/Schema/Directives/UpsertDirective.php | 2 +- .../Schema/Directives/UpsertDirectiveTest.php | 29 ++++++++++--------- 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/Execution/Arguments/UpsertModel.php b/src/Execution/Arguments/UpsertModel.php index fc39ed46e7..e726d5bc60 100644 --- a/src/Execution/Arguments/UpsertModel.php +++ b/src/Execution/Arguments/UpsertModel.php @@ -28,14 +28,14 @@ public function __invoke($model, $args): mixed // TODO consider Laravel native ->upsert(), available from 8.10 $existingModel = null; - if (!empty($this->identifyingColumns)) { + if (! empty($this->identifyingColumns)) { $existingModel = $model ->newQuery() ->firstWhere( - array_intersect_key( + array_intersect_key( $args->toArray(), - array_flip($this->identifyingColumns) - ) + array_flip($this->identifyingColumns), + ), ); if ($existingModel !== null) { diff --git a/src/Schema/Directives/UpsertDirective.php b/src/Schema/Directives/UpsertDirective.php index 954983e429..063b25ceec 100644 --- a/src/Schema/Directives/UpsertDirective.php +++ b/src/Schema/Directives/UpsertDirective.php @@ -41,7 +41,7 @@ protected function makeExecutionFunction(Relation $parentRelation = null): calla { return new UpsertModel( new SaveModel($parentRelation), - $this->directiveArgValue('identifyingColumns') + $this->directiveArgValue('identifyingColumns'), ); } } diff --git a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php index 249ad2e81c..848899baff 100644 --- a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php @@ -226,8 +226,8 @@ public function testDirectUpsertByIdentifyingColumn(): void ')->assertJson([ 'data' => [ 'upsertUser' => [ - 'email' => "foo@te.st", - 'name' => "bar" + 'email' => 'foo@te.st', + 'name' => 'bar', ], ], ]); @@ -250,8 +250,8 @@ public function testDirectUpsertByIdentifyingColumn(): void ')->assertJson([ 'data' => [ 'upsertUser' => [ - 'email' => "foo@te.st", - 'name' => "foo" + 'email' => 'foo@te.st', + 'name' => 'foo', ], ], ]); @@ -266,9 +266,9 @@ public function testDirectUpsertByIdentifyingColumns(): void { $company = factory(Company::class)->create(['id' => 1]); - $this->schema .= + $this->schema /** @lang GraphQL */ - ' + .= ' type User { id: ID! email: String! @@ -295,12 +295,13 @@ public function testDirectUpsertByIdentifyingColumns(): void company_id } } - ')->assertJson([ + ', + )->assertJson([ 'data' => [ 'upsertUser' => [ - 'email' => "foo@te.st", - 'name' => "bar", - 'company_id' => 1 + 'email' => 'foo@te.st', + 'name' => 'bar', + 'company_id' => 1, ], ], ]); @@ -325,13 +326,13 @@ public function testDirectUpsertByIdentifyingColumns(): void company_id } } - ' + ', )->assertJson([ 'data' => [ 'upsertUser' => [ - 'email' => "bar@te.st", - 'name' => "bar", - 'company_id' => $company->id + 'email' => 'bar@te.st', + 'name' => 'bar', + 'company_id' => $company->id, ], ], ]); From 0472f0327bbd5324353930c0d8c5f737599afc9b Mon Sep 17 00:00:00 2001 From: GavG Date: Fri, 28 Jul 2023 09:17:43 +0100 Subject: [PATCH 03/31] update changelog --- CHANGELOG.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5da6fe3e5..159f464de6 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 support for identifyingColumns on upserts + ## v6.15.0 ### Added @@ -524,7 +528,7 @@ You can find and compare releases at the [GitHub release page](https://github.co ### Fixed -- Distinguish between client-safe and non-client-safe errors in `TestResponse::assertGraphQLError()` +- Distinguish between client-safe and non-client-safe errors in `TestResponse::assertGraphQLError()` ## v5.46.0 @@ -831,7 +835,7 @@ You can find and compare releases at the [GitHub release page](https://github.co ### Fixed -- Avoid PHP 8.1 deprecation warning by implementing `__serialize()` and `__unserialize()` https://github.com/nuwave/lighthouse/pull/1987 +- Avoid PHP 8.1 deprecation warning by implementing `__serialize()` and `__unserialize()` https://github.com/nuwave/lighthouse/pull/1987 ### Deprecated @@ -1107,7 +1111,7 @@ You can find and compare releases at the [GitHub release page](https://github.co ### Changed -- Improve performance through [`graphql-php` lazy field definitions](https://github.com/webonyx/graphql-php/pull/861) https://github.com/nuwave/lighthouse/pull/1851 +- Improve performance through [`graphql-php` lazy field definitions](https://github.com/webonyx/graphql-php/pull/861) https://github.com/nuwave/lighthouse/pull/1851 - Load individual subscription fields lazily instead of loading them all eagerly https://github.com/nuwave/lighthouse/pull/1851 - Require `webonyx/graphql-php:^14.7` https://github.com/nuwave/lighthouse/pull/1851 From faa6323810299987c2bb3a7976ef616cd61693f6 Mon Sep 17 00:00:00 2001 From: spawnia Date: Tue, 10 Feb 2026 13:06:50 +0100 Subject: [PATCH 04/31] Normalize GraphQL test literals to #2748 after merging master --- .../Schema/Directives/UpsertDirectiveTest.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php index 49de913554..5f5f99d2af 100644 --- a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php @@ -205,7 +205,7 @@ interface IUser public function testDirectUpsertByIdentifyingColumn(): void { - $this->schema .= /** @lang GraphQL */ ' + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' type User { id: ID! email: String! @@ -215,9 +215,9 @@ public function testDirectUpsertByIdentifyingColumn(): void type Mutation { upsertUser(name: String!, email: String!): User @upsert(identifyingColumns: ["email"]) } - '; + GRAPHQL; - $this->graphQL(/** @lang GraphQL */ ' + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation { upsertUser( email: "foo@te.st" @@ -227,7 +227,7 @@ public function testDirectUpsertByIdentifyingColumn(): void email } } - ')->assertJson([ + GRAPHQL)->assertJson([ 'data' => [ 'upsertUser' => [ 'email' => 'foo@te.st', @@ -241,7 +241,7 @@ public function testDirectUpsertByIdentifyingColumn(): void $this->assertSame('bar', $user->name); $this->assertSame('foo@te.st', $user->email); - $this->graphQL(/** @lang GraphQL */ ' + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation { upsertUser( email: "foo@te.st" @@ -251,7 +251,7 @@ public function testDirectUpsertByIdentifyingColumn(): void email } } - ')->assertJson([ + GRAPHQL)->assertJson([ 'data' => [ 'upsertUser' => [ 'email' => 'foo@te.st', From f0eaca6ccab1b23482ac0bc7df4d169f7279f6a0 Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 14:10:40 +0100 Subject: [PATCH 05/31] Continue work --- CHANGELOG.md | 6 +- docs/master/api-reference/directives.md | 35 +++ docs/master/eloquent/getting-started.md | 9 + src/Execution/Arguments/UpsertModel.php | 84 +++++-- src/Schema/Directives/UpsertDirective.php | 1 + src/Schema/Directives/UpsertManyDirective.php | 12 +- .../Schema/Directives/UpsertDirectiveTest.php | 221 ++++++++++++++++- .../Directives/UpsertManyDirectiveTest.php | 230 +++++++++++++++++- 8 files changed, 571 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de383541f3..a04258c7f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,11 @@ You can find and compare releases at the [GitHub release page](https://github.co ### Added -- Add support for identifyingColumns on upserts https://github.com/nuwave/lighthouse/pull/2426 +- Add support for `identifyingColumns` on `@upsert` and `@upsertMany` https://github.com/nuwave/lighthouse/pull/2426 + +### Changed + +- Scope nested `@upsert` and `@upsertMany` lookups to their parent relation https://github.com/nuwave/lighthouse/pull/2426 ## v6.64.3 diff --git a/docs/master/api-reference/directives.md b/docs/master/api-reference/directives.md index 097ba943c4..fe05ee6059 100644 --- a/docs/master/api-reference/directives.md +++ b/docs/master/api-reference/directives.md @@ -4024,6 +4024,12 @@ directive @upsert( """ model: String + """ + Specify the columns by which to upsert the model. + This is optional, defaults to the ID or model key. + """ + identifyingColumns: [String!] = [] + """ Specify the name of the relation on the parent model. This is only needed when using this directive as a nested arg @@ -4043,6 +4049,15 @@ type Mutation { } ``` +When you pass `identifyingColumns`, Lighthouse will first try to match an existing model through those columns and only then fall back to `id`. + +```graphql +type Mutation { + upsertUser(email: String!, name: String!): User! + @upsert(identifyingColumns: ["email"]) +} +``` + This directive can also be used as a [nested arg resolver](../concepts/arg-resolvers.md). ## @upsertMany @@ -4058,6 +4073,12 @@ directive @upsertMany( """ model: String + """ + Specify the columns by which to upsert the model. + This is optional, defaults to the ID or model key. + """ + identifyingColumns: [String!] = [] + """ Specify the name of the relation on the parent model. This is only needed when using this directive as a nested arg @@ -4080,6 +4101,20 @@ input UpsertPostInput { } ``` +You can also use `identifyingColumns` with `@upsertMany`: + +```graphql +type Mutation { + upsertUsers(inputs: [UpsertUserInput!]!): [User!]! + @upsertMany(identifyingColumns: ["email"]) +} + +input UpsertUserInput { + email: String! + name: String! +} +``` + ## @validator ```graphql diff --git a/docs/master/eloquent/getting-started.md b/docs/master/eloquent/getting-started.md index 9b58e3bade..6bc8f01cdb 100644 --- a/docs/master/eloquent/getting-started.md +++ b/docs/master/eloquent/getting-started.md @@ -260,6 +260,15 @@ type Mutation { } ``` +If you want to identify records by custom fields, pass `identifyingColumns`: + +```graphql +type Mutation { + upsertUser(name: String!, email: String!): User! + @upsert(identifyingColumns: ["email"]) +} +``` + Since upsert can create or update your data, your input should mark the minimum required fields as non-nullable. ```graphql diff --git a/src/Execution/Arguments/UpsertModel.php b/src/Execution/Arguments/UpsertModel.php index 8238b0b354..066475df66 100644 --- a/src/Execution/Arguments/UpsertModel.php +++ b/src/Execution/Arguments/UpsertModel.php @@ -2,22 +2,32 @@ namespace Nuwave\Lighthouse\Execution\Arguments; +use GraphQL\Error\Error; +use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\Relation; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; class UpsertModel implements ArgResolver { + public const MISSING_IDENTIFYING_COLUMNS_FOR_UPSERT = 'All configured identifying columns must be present and non-null for upsert.'; + + public const CANNOT_UPSERT_UNRELATED_MODEL = 'Cannot upsert a model that is not related to the given parent.'; + /** @var callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver */ protected $previous; - /** @var array */ - protected array $identifyingColumns; - - /** @param callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver $previous */ - public function __construct(callable $previous, ?array $identifyingColumns) - { + /** + * @param callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver $previous + * @param array|null $identifyingColumns + */ + public function __construct( + callable $previous, + protected ?array $identifyingColumns = null, + /** @var \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null $parentRelation */ + protected ?Relation $parentRelation = null, + ) { $this->previous = $previous; - $this->identifyingColumns = $identifyingColumns ?? []; } /** @@ -29,15 +39,18 @@ public function __invoke($model, $args): mixed // TODO consider Laravel native ->upsert(), available from 8.10 $existingModel = null; - if (! empty($this->identifyingColumns)) { - $existingModel = $model - ->newQuery() - ->firstWhere( - array_intersect_key( - $args->toArray(), - array_flip($this->identifyingColumns), - ), - ); + if ($this->identifyingColumns) { + $identifyingColumns = $this->identifyingColumnValues($args, $this->identifyingColumns) + ?? throw new Error(self::MISSING_IDENTIFYING_COLUMNS_FOR_UPSERT); + + $existingModel = $this->queryBuilder($model)->firstWhere($identifyingColumns); + if ( + $existingModel === null + && $this->parentRelation !== null + && $model->newQuery()->where($identifyingColumns)->exists() + ) { + throw new Error(self::CANNOT_UPSERT_UNRELATED_MODEL); + } if ($existingModel !== null) { $model = $existingModel; @@ -47,9 +60,14 @@ public function __invoke($model, $args): mixed if ($existingModel === null) { $id = $this->retrieveID($model, $args); if ($id) { - $existingModel = $model - ->newQuery() - ->find($id); + $existingModel = $this->queryBuilder($model)->find($id); + if ( + $existingModel === null + && $this->parentRelation !== null + && $model->newQuery()->find($id) !== null + ) { + throw new Error(self::CANNOT_UPSERT_UNRELATED_MODEL); + } if ($existingModel !== null) { $model = $existingModel; @@ -60,6 +78,27 @@ public function __invoke($model, $args): mixed return ($this->previous)($model, $args); } + /** @return array|null */ + protected function identifyingColumnValues(ArgumentSet $args, array $identifyingColumns): ?array + { + $identifyingValues = array_intersect_key( + $args->toArray(), + array_flip($identifyingColumns), + ); + + if (count($identifyingValues) !== count($identifyingColumns)) { + return null; + } + + foreach ($identifyingValues as $identifyingColumn) { + if ($identifyingColumn === null) { + return null; + } + } + + return $identifyingValues; + } + /** @return mixed The value of the ID or null */ protected function retrieveID(Model $model, ArgumentSet $args) { @@ -79,4 +118,11 @@ protected function retrieveID(Model $model, ArgumentSet $args) return null; } + + /** @return \Illuminate\Database\Eloquent\Builder<\Illuminate\Database\Eloquent\Model> */ + protected function queryBuilder(Model $model): EloquentBuilder + { + return $this->parentRelation?->getQuery() + ?? $model->newQuery(); + } } diff --git a/src/Schema/Directives/UpsertDirective.php b/src/Schema/Directives/UpsertDirective.php index 0584508159..f933c08f60 100644 --- a/src/Schema/Directives/UpsertDirective.php +++ b/src/Schema/Directives/UpsertDirective.php @@ -42,6 +42,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 7cd48f9fca..efe73051c5 100644 --- a/src/Schema/Directives/UpsertManyDirective.php +++ b/src/Schema/Directives/UpsertManyDirective.php @@ -21,6 +21,12 @@ public static function definition(): string """ model: String + """ + Specify the columns by which to upsert the model. + This is optional, defaults to the ID or model Key. + """ + identifyingColumns: [String!] = [] + """ Specify the name of the relation on the parent model. This is only needed when using this directive as a nested arg @@ -33,6 +39,10 @@ public static function definition(): string protected function makeExecutionFunction(?Relation $parentRelation = null): callable { - return new UpsertModel(new SaveModel($parentRelation)); + return new UpsertModel( + new SaveModel($parentRelation), + $this->directiveArgValue('identifyingColumns'), + $parentRelation, + ); } } diff --git a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php index 5f5f99d2af..14ef234253 100644 --- a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php @@ -4,6 +4,7 @@ use GraphQL\Type\Definition\Type; use Illuminate\Container\Container; +use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use Nuwave\Lighthouse\Schema\TypeRegistry; use Tests\DBTestCase; use Tests\Utils\Models\Company; @@ -14,11 +15,15 @@ final class UpsertDirectiveTest extends DBTestCase { public function testNestedArgResolver(): void { - factory(User::class)->create(); + $user = factory(User::class)->make(); + $user->id = 1; + $user->save(); - $task = factory(Task::class)->create(); - $this->assertInstanceOf(Task::class, $task); + $task = factory(Task::class)->make(); $task->id = 1; + $task->user()->associate($user); + $task->save(); + $this->assertInstanceOf(Task::class, $task); $task->name = 'old'; $task->save(); @@ -268,7 +273,9 @@ public function testDirectUpsertByIdentifyingColumn(): void public function testDirectUpsertByIdentifyingColumns(): void { - $company = factory(Company::class)->create(['id' => 1]); + $company = factory(Company::class)->make(); + $company->id = 1; + $company->save(); $this->schema /** @lang GraphQL */ @@ -347,6 +354,212 @@ public function testDirectUpsertByIdentifyingColumns(): void $this->assertSame('bar@te.st', $user->email); } + public function testDirectUpsertByIdentifyingColumnsRequiresAllConfiguredColumns(): void + { + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + email: String + name: String + } + + type Mutation { + upsertUser(name: String!, email: String): User @upsert(identifyingColumns: ["name", "email"]) + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + upsertUser(name: "foo") { + id + } + } + GRAPHQL)->assertGraphQLErrorMessage(UpsertModel::MISSING_IDENTIFYING_COLUMNS_FOR_UPSERT); + } + + public function testUpsertByIdentifyingColumnWithInputSpread(): void + { + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + email: String! + name: String! + } + + input UpsertUserInput { + email: String! + name: String! + } + + type Mutation { + upsertUser(input: UpsertUserInput! @spread): User! @upsert(identifyingColumns: ["email"]) + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + upsertUser(input: { + email: "foo@te.st" + name: "bar" + }) { + email + name + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'upsertUser' => [ + 'email' => 'foo@te.st', + 'name' => 'bar', + ], + ], + ]); + + $this->assertSame(1, User::count()); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + upsertUser(input: { + email: "foo@te.st" + name: "baz" + }) { + email + name + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'upsertUser' => [ + 'email' => 'foo@te.st', + 'name' => 'baz', + ], + ], + ]); + + $this->assertSame(1, User::count()); + $this->assertSame('baz', User::firstOrFail()->name); + } + + public function testNestedUpsertByIdDoesNotModifySiblingParentsRelatedModel(): void + { + $userA = factory(User::class)->create(); + $userB = factory(User::class)->create(); + $taskA = factory(Task::class)->make(); + $taskA->name = 'from-user-a'; + $taskA->user()->associate($userA); + $taskA->save(); + + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + updateUser(input: UpdateUserInput! @spread): User @update + } + + type Task { + id: Int + name: String! + } + + type User { + id: Int + tasks: [Task!]! @hasMany + } + + input UpdateUserInput { + id: Int + tasks: [UpdateTaskInput!] @upsert(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' => $userB->id, + 'taskID' => $taskA->id, + ], + )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + + $taskA->refresh(); + $this->assertSame($userA->id, $taskA->user_id); + $this->assertSame('from-user-a', $taskA->name); + } + + public function testNestedUpsertByIdentifyingColumnDoesNotModifySiblingParentsRelatedModel(): void + { + $userA = factory(User::class)->create(); + $userB = factory(User::class)->create(); + $taskA = factory(Task::class)->make(); + $taskA->name = 'same-name'; + $taskA->difficulty = 1; + $taskA->user()->associate($userA); + $taskA->save(); + + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + updateUser(input: UpdateUserInput! @spread): User @update + } + + type Task { + id: Int + name: String! + difficulty: Int + } + + type User { + id: Int + tasks: [Task!]! @hasMany + } + + input UpdateUserInput { + id: Int + tasks: [UpdateTaskInput!] @upsert(relation: "tasks", identifyingColumns: ["name"]) + } + + input UpdateTaskInput { + id: Int + name: String! + difficulty: Int + } + GRAPHQL; + + $this->graphQL( + /** @lang GraphQL */ <<<'GRAPHQL' + mutation ($userID: Int!) { + updateUser(input: { + id: $userID + tasks: [{ name: "same-name", difficulty: 2 }] + }) { + id + tasks { + name + difficulty + } + } + } + GRAPHQL, + [ + 'userID' => $userB->id, + ], + )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + + $taskA->refresh(); + $this->assertSame($userA->id, $taskA->user_id); + $this->assertSame(1, $taskA->difficulty); + } + public static function resolveType(): Type { $typeRegistry = Container::getInstance()->make(TypeRegistry::class); diff --git a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php index 0aa8380d3b..fb26e5c3bb 100644 --- a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php @@ -4,6 +4,7 @@ use GraphQL\Type\Definition\Type; use Illuminate\Container\Container; +use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use Nuwave\Lighthouse\Schema\TypeRegistry; use Tests\DBTestCase; use Tests\Utils\Models\Task; @@ -13,10 +14,13 @@ final class UpsertManyDirectiveTest extends DBTestCase { public function testNestedArgResolver(): void { - factory(User::class)->create(); + $user = factory(User::class)->make(); + $user->id = 1; + $user->save(); - $task = factory(Task::class)->create(); + $task = factory(Task::class)->make(); $this->assertInstanceOf(Task::class, $task); + $task->user()->associate($user); $task->id = 1; $task->name = 'old'; $task->save(); @@ -213,6 +217,228 @@ interface IUser ]); } + public function testDirectUpsertManyByIdentifyingColumn(): void + { + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + email: String! + name: String! + } + + input UpsertUserInput { + email: String! + name: String! + } + + type Mutation { + upsertUsers(inputs: [UpsertUserInput!]!): [User!]! @upsertMany(identifyingColumns: ["email"]) + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + upsertUsers(inputs: [ + { email: "foo@te.st", name: "bar" } + { email: "baz@te.st", name: "qux" } + ]) { + email + name + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'upsertUsers' => [ + [ + 'email' => 'foo@te.st', + 'name' => 'bar', + ], + [ + 'email' => 'baz@te.st', + 'name' => 'qux', + ], + ], + ], + ]); + + $this->assertSame(2, User::count()); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + upsertUsers(inputs: [ + { email: "foo@te.st", name: "updated" } + { email: "baz@te.st", name: "qux" } + ]) { + email + name + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'upsertUsers' => [ + [ + 'email' => 'foo@te.st', + 'name' => 'updated', + ], + [ + 'email' => 'baz@te.st', + 'name' => 'qux', + ], + ], + ], + ]); + + $this->assertSame(2, User::count()); + $this->assertSame('updated', User::where('email', 'foo@te.st')->firstOrFail()->name); + } + + public function testDirectUpsertManyByIdentifyingColumnsRequiresAllConfiguredColumns(): void + { + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + email: String + name: String + } + + input UpsertUserInput { + email: String + name: String! + } + + type Mutation { + upsertUsers(inputs: [UpsertUserInput!]!): [User!]! @upsertMany(identifyingColumns: ["name", "email"]) + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + upsertUsers(inputs: [{ name: "foo" }]) { + id + } + } + GRAPHQL)->assertGraphQLErrorMessage(UpsertModel::MISSING_IDENTIFYING_COLUMNS_FOR_UPSERT); + } + + public function testNestedUpsertManyByIdDoesNotModifySiblingParentsRelatedModel(): void + { + $userA = factory(User::class)->create(); + $userB = factory(User::class)->create(); + $taskA = factory(Task::class)->make(); + $taskA->name = 'from-user-a'; + $taskA->user()->associate($userA); + $taskA->save(); + + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + updateUser(input: UpdateUserInput! @spread): User @update + } + + type Task { + id: Int + name: String! + } + + type User { + id: Int + tasks: [Task!]! @hasMany + } + + 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' => $userB->id, + 'taskID' => $taskA->id, + ], + )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + + $taskA->refresh(); + $this->assertSame($userA->id, $taskA->user_id); + $this->assertSame('from-user-a', $taskA->name); + } + + public function testNestedUpsertManyByIdentifyingColumnDoesNotModifySiblingParentsRelatedModel(): void + { + $userA = factory(User::class)->create(); + $userB = factory(User::class)->create(); + $taskA = factory(Task::class)->make(); + $taskA->name = 'same-name'; + $taskA->difficulty = 1; + $taskA->user()->associate($userA); + $taskA->save(); + + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + updateUser(input: UpdateUserInput! @spread): User @update + } + + type Task { + id: Int + name: String! + difficulty: Int + } + + type User { + id: Int + tasks: [Task!]! @hasMany + } + + input UpdateUserInput { + id: Int + tasks: [UpdateTaskInput!] @upsertMany(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: "same-name", difficulty: 2 }] + }) { + id + tasks { + name + difficulty + } + } + } + GRAPHQL, + [ + 'userID' => $userB->id, + ], + )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + + $taskA->refresh(); + $this->assertSame($userA->id, $taskA->user_id); + $this->assertSame(1, $taskA->difficulty); + } + public static function resolveType(): Type { $typeRegistry = Container::getInstance()->make(TypeRegistry::class); From 6e6aa17fec47be98969784bd046dc95ff0b96cb6 Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 14:26:51 +0100 Subject: [PATCH 06/31] Scope nested upserts to parent relations and expand coverage --- src/Execution/Arguments/NestedBelongsTo.php | 2 +- src/Execution/Arguments/NestedManyToMany.php | 2 +- src/Execution/Arguments/NestedOneToMany.php | 2 +- src/Execution/Arguments/NestedOneToOne.php | 2 +- src/Execution/Arguments/UpsertModel.php | 8 ++- .../MutationExecutor/BelongsToManyTest.php | 62 +++++++++++++++-- .../MutationExecutor/BelongsToTest.php | 69 +++++++++++++++++-- .../MutationExecutor/HasManyTest.php | 33 +++++++++ .../Execution/MutationExecutor/HasOneTest.php | 33 +++++++++ .../MutationExecutor/MorphManyTest.php | 33 +++++++++ .../MutationExecutor/MorphOneTest.php | 53 ++++++++++---- .../MutationExecutor/MorphToManyTest.php | 33 +++++++++ .../Schema/Directives/UpsertDirectiveTest.php | 24 +++---- .../Directives/UpsertManyDirectiveTest.php | 24 +++---- 14 files changed, 321 insertions(+), 59 deletions(-) diff --git a/src/Execution/Arguments/NestedBelongsTo.php b/src/Execution/Arguments/NestedBelongsTo.php index 7a446f0840..06f931ff13 100644 --- a/src/Execution/Arguments/NestedBelongsTo.php +++ b/src/Execution/Arguments/NestedBelongsTo.php @@ -41,7 +41,7 @@ public function __invoke($model, $args): void } if ($args->has('upsert')) { - $upsertModel = new ResolveNested(new UpsertModel(new SaveModel())); + $upsertModel = new ResolveNested(new UpsertModel(new SaveModel(), null, $this->relation)); $related = $upsertModel( $this->relation->make(), $args->arguments['upsert']->value, diff --git a/src/Execution/Arguments/NestedManyToMany.php b/src/Execution/Arguments/NestedManyToMany.php index a2ac78e90b..ae95f7432b 100644 --- a/src/Execution/Arguments/NestedManyToMany.php +++ b/src/Execution/Arguments/NestedManyToMany.php @@ -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), null, $relation)); foreach ($args->arguments['upsert']->value as $childArgs) { // @phpstan-ignore-next-line Relation&Builder mixin not recognized diff --git a/src/Execution/Arguments/NestedOneToMany.php b/src/Execution/Arguments/NestedOneToMany.php index 8b2c216aa1..ee120162f6 100644 --- a/src/Execution/Arguments/NestedOneToMany.php +++ b/src/Execution/Arguments/NestedOneToMany.php @@ -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), null, $relation)); foreach ($args->arguments['upsert']->value as $childArgs) { // @phpstan-ignore-next-line Relation&Builder mixin not recognized diff --git a/src/Execution/Arguments/NestedOneToOne.php b/src/Execution/Arguments/NestedOneToOne.php index e08d551c5a..7c630f4e07 100644 --- a/src/Execution/Arguments/NestedOneToOne.php +++ b/src/Execution/Arguments/NestedOneToOne.php @@ -42,7 +42,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), null, $relation)); $upsertModel($relation->first() ?? $relation->make(), $args->arguments['upsert']->value); } diff --git a/src/Execution/Arguments/UpsertModel.php b/src/Execution/Arguments/UpsertModel.php index 066475df66..b741e4d06b 100644 --- a/src/Execution/Arguments/UpsertModel.php +++ b/src/Execution/Arguments/UpsertModel.php @@ -19,7 +19,7 @@ class UpsertModel implements ArgResolver /** * @param callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver $previous - * @param array|null $identifyingColumns + * @param array|null $identifyingColumns */ public function __construct( callable $previous, @@ -78,7 +78,11 @@ public function __invoke($model, $args): mixed return ($this->previous)($model, $args); } - /** @return array|null */ + /** + * @param array $identifyingColumns + * + * @return array|null + */ protected function identifyingColumnValues(ArgumentSet $args, array $identifyingColumns): ?array { $identifyingValues = array_intersect_key( diff --git a/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php b/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php index e164f7b50f..0581a837d6 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\UpsertModel; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\Role; @@ -314,6 +315,37 @@ public function testUpsertBelongsToManyWithoutId(): void $this->assertSame('is_user', $role->name); } + public function testNestedUpsertByIDDoesNotModifyUnrelatedBelongsToManyModel(): void + { + $roleA = factory(Role::class)->create(); + $roleB = factory(Role::class)->create(); + $userA = factory(User::class)->create(); + + $roleA->users()->attach($userA); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($roleID: ID!, $userID: ID!) { + upsertRole(input: { + id: $roleID + name: "role-b" + users: { + upsert: [{ id: $userID, name: "hacked" }] + } + }) { + id + } + } + GRAPHQL, [ + 'roleID' => $roleB->id, + 'userID' => $userA->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + + $userA->refresh(); + $this->assertSame($roleA->id, $userA->roles()->firstOrFail()->id); + $this->assertNotSame('hacked', $userA->name); + $this->assertCount(0, $roleB->users()->get()); + } + public function testCreateAndConnectWithBelongsToMany(): void { $user = factory(User::class)->make(); @@ -578,23 +610,27 @@ public function testUpdateWithBelongsToMany(string $action): void $role->name = 'is_admin'; $role->save(); + $users = factory(User::class, 2)->create(); $role->users() ->attach( - factory(User::class, 2)->create(), + $users, ); - $this->graphQL(/** @lang GraphQL */ <<id; + $secondUserID = $users[1]->id; + + $response = $this->graphQL(/** @lang GraphQL */ <<assertJson([ + GRAPHQL); + + if ($action === 'upsert') { + $response->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + + $role->refresh(); + $this->assertCount(2, $role->users()->get()); + $this->assertSame('is_admin', $role->name); + + return; + } + + $response->assertJson([ 'data' => [ "{$action}Role" => [ 'id' => '1', 'name' => 'is_user', 'users' => [ [ - 'id' => '1', + 'id' => (string) $firstUserID, 'name' => 'user1', ], [ - 'id' => '2', + 'id' => (string) $secondUserID, 'name' => 'user2', ], ], diff --git a/tests/Integration/Execution/MutationExecutor/BelongsToTest.php b/tests/Integration/Execution/MutationExecutor/BelongsToTest.php index 660a9f4575..55df160e8d 100644 --- a/tests/Integration/Execution/MutationExecutor/BelongsToTest.php +++ b/tests/Integration/Execution/MutationExecutor/BelongsToTest.php @@ -4,6 +4,7 @@ use Illuminate\Database\Events\QueryExecuted; use Illuminate\Support\Facades\DB; +use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\Role; @@ -278,6 +279,39 @@ public function testUpsertWithNewBelongsTo(): void ]); } + public function testNestedUpsertByIDDoesNotModifyUnrelatedBelongsToModel(): void + { + $userA = factory(User::class)->create(); + $userB = factory(User::class)->create(); + $task = factory(Task::class)->create(); + $task->user()->associate($userB); + $task->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($taskID: ID!, $userID: ID!) { + upsertTask(input: { + id: $taskID + name: "task" + user: { + upsert: { id: $userID, name: "hacked" } + } + }) { + id + } + } + GRAPHQL, + [ + 'taskID' => $task->id, + 'userID' => $userA->id, + ], + )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + + $userA->refresh(); + $task->refresh(); + $this->assertNotSame('hacked', $userA->name); + $this->assertSame($userB->id, $task->user_id); + } + public function testUpsertBelongsToWithoutID(): void { $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' @@ -607,10 +641,37 @@ public function testSavesOnlyOnceWithMultipleBelongsTo(): void public function testUpsertUsingCreateAndUpdateUsingUpsertBelongsTo(): void { - $user = factory(User::class)->make(); - $this->assertInstanceOf(User::class, $user); - $user->name = 'foo'; - $user->save(); + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + upsertTask(input: { + id: 1 + name: "foo" + user: { + upsert: { + name: "foo-user" + } + } + }) { + id + name + user { + id + name + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'upsertTask' => [ + 'id' => '1', + 'name' => 'foo', + 'user' => [ + 'id' => '1', + 'name' => 'foo-user', + ], + ], + ], + ]); $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation { diff --git a/tests/Integration/Execution/MutationExecutor/HasManyTest.php b/tests/Integration/Execution/MutationExecutor/HasManyTest.php index bf564e1de9..c3453722d2 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\UpsertModel; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\CustomPrimaryKey; @@ -315,6 +316,38 @@ public function testCreateUsingUpsertWithNewHasMany(): void ]); } + public function testNestedUpsertByIDDoesNotModifyUnrelatedHasManyModel(): void + { + $userA = factory(User::class)->create(); + $userB = factory(User::class)->create(); + + $taskA = factory(Task::class)->make(); + $taskA->name = 'from-user-a'; + $taskA->user()->associate($userA); + $taskA->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($userID: ID!, $taskID: ID!) { + upsertUser(input: { + id: $userID + name: "user-b" + tasks: { + upsert: [{ id: $taskID, name: "hacked" }] + } + }) { + id + } + } + GRAPHQL, [ + 'userID' => $userB->id, + 'taskID' => $taskA->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + + $taskA->refresh(); + $this->assertSame('from-user-a', $taskA->name); + $this->assertSame($userA->id, $taskA->user_id); + } + /** @return iterable */ public static function existingModelMutations(): iterable { diff --git a/tests/Integration/Execution/MutationExecutor/HasOneTest.php b/tests/Integration/Execution/MutationExecutor/HasOneTest.php index b6856e22c7..638ea93e7f 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\UpsertModel; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\Post; @@ -184,6 +185,38 @@ public function testCreateUsingUpsertWithNewHasOne(): void ]); } + public function testNestedUpsertByIDDoesNotModifyUnrelatedHasOneModel(): void + { + $taskA = factory(Task::class)->create(); + $taskB = factory(Task::class)->create(); + + $postA = factory(Post::class)->make(); + $postA->title = 'from-task-a'; + $postA->task()->associate($taskA); + $postA->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($taskID: ID!, $postID: ID!) { + upsertTask(input: { + id: $taskID + name: "task-b" + post: { + upsert: { id: $postID, title: "hacked" } + } + }) { + id + } + } + GRAPHQL, [ + 'taskID' => $taskB->id, + 'postID' => $postA->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + + $postA->refresh(); + $this->assertSame('from-task-a', $postA->title); + $this->assertSame($taskA->id, $postA->task_id); + } + public function testUpsertHasOneWithoutID(): void { $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' diff --git a/tests/Integration/Execution/MutationExecutor/MorphManyTest.php b/tests/Integration/Execution/MutationExecutor/MorphManyTest.php index 497cd79de4..f71292cc83 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\UpsertModel; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\Image; @@ -226,6 +227,38 @@ public function testUpsertMorphManyWithoutId(): void ]); } + public function testNestedUpsertByIDDoesNotModifyUnrelatedMorphManyModel(): void + { + $taskA = factory(Task::class)->create(); + $taskB = factory(Task::class)->create(); + + $imageA = factory(Image::class)->make(); + $imageA->url = 'from-task-a'; + $imageA->imageable()->associate($taskA); + $imageA->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($taskID: ID!, $imageID: ID!) { + upsertTask(input: { + id: $taskID + name: "task-b" + images: { + upsert: [{ id: $imageID, url: "hacked" }] + } + }) { + id + } + } + GRAPHQL, [ + 'taskID' => $taskB->id, + 'imageID' => $imageA->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + + $imageA->refresh(); + $this->assertSame('from-task-a', $imageA->url); + $this->assertSame($taskA->id, $imageA->imageable_id); + } + public function testAllowsNullOperations(): void { factory(Task::class)->create(); diff --git a/tests/Integration/Execution/MutationExecutor/MorphOneTest.php b/tests/Integration/Execution/MutationExecutor/MorphOneTest.php index 915e287a39..89d8e7ad13 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\UpsertModel; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\Image; @@ -177,6 +178,38 @@ public function testUpsertMorphOneWithoutId(): void ]); } + public function testNestedUpsertByIDDoesNotModifyUnrelatedMorphOneModel(): void + { + $taskA = factory(Task::class)->create(); + $taskB = factory(Task::class)->create(); + + $imageA = factory(Image::class)->make(); + $imageA->url = 'from-task-a'; + $imageA->imageable()->associate($taskA); + $imageA->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($taskID: ID!, $imageID: ID!) { + upsertTask(input: { + id: $taskID + name: "task-b" + image: { + upsert: { id: $imageID, url: "hacked" } + } + }) { + id + } + } + GRAPHQL, [ + 'taskID' => $taskB->id, + 'imageID' => $imageA->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + + $imageA->refresh(); + $this->assertSame('from-task-a', $imageA->url); + $this->assertSame($taskA->id, $imageA->imageable_id); + } + public function testAllowsNullOperations(): void { factory(Task::class)->create(); @@ -379,8 +412,10 @@ public function testNestedConnectMorphOne(): void $task = factory(Task::class)->create(); $this->assertInstanceOf(Task::class, $task); - $image = factory(Image::class)->create(); + $image = factory(Image::class)->make(); $this->assertInstanceOf(Image::class, $image); + $image->url = 'original'; + $image->save(); $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation ($input: UpdateTaskInput!) { @@ -403,16 +438,10 @@ public function testNestedConnectMorphOne(): void ], ], ], - ])->assertJson([ - 'data' => [ - 'updateTask' => [ - 'id' => '1', - 'name' => 'foo', - 'image' => [ - 'url' => 'foo', - ], - ], - ], - ]); + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + + $image->refresh(); + $this->assertSame('original', $image->url); + $this->assertNull($image->imageable_id); } } diff --git a/tests/Integration/Execution/MutationExecutor/MorphToManyTest.php b/tests/Integration/Execution/MutationExecutor/MorphToManyTest.php index 2114e23f3b..8e8714946a 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\UpsertModel; use Tests\DBTestCase; use Tests\Utils\Models\Tag; +use Tests\Utils\Models\Task; final class MorphToManyTest extends DBTestCase { @@ -222,6 +224,37 @@ public function testUpsertATaskWithExistingTagsByUsingSync(): void ]); } + public function testNestedUpsertByIDDoesNotModifyUnrelatedMorphToManyModel(): void + { + $taskA = factory(Task::class)->create(); + $taskB = factory(Task::class)->create(); + $tagA = factory(Tag::class)->create(); + + $taskA->tags()->attach($tagA); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($taskID: ID!, $tagID: ID!) { + upsertTask(input: { + id: $taskID + name: "task-b" + tags: { + upsert: [{ id: $tagID, name: "hacked" }] + } + }) { + id + } + } + GRAPHQL, [ + 'taskID' => $taskB->id, + 'tagID' => $tagA->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + + $tagA->refresh(); + $this->assertNotSame('hacked', $tagA->name); + $this->assertCount(1, $taskA->tags()->whereKey($tagA->id)->get()); + $this->assertCount(0, $taskB->tags()->whereKey($tagA->id)->get()); + } + public function testCreateANewTagRelationByUsingCreate(): void { $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' diff --git a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php index 14ef234253..35e71e7133 100644 --- a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php @@ -475,8 +475,7 @@ public function testNestedUpsertByIdDoesNotModifySiblingParentsRelatedModel(): v } GRAPHQL; - $this->graphQL( - /** @lang GraphQL */ <<<'GRAPHQL' + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation ($userID: Int!, $taskID: Int!) { updateUser(input: { id: $userID @@ -485,12 +484,10 @@ public function testNestedUpsertByIdDoesNotModifySiblingParentsRelatedModel(): v id } } - GRAPHQL, - [ - 'userID' => $userB->id, - 'taskID' => $taskA->id, - ], - )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + GRAPHQL, [ + 'userID' => $userB->id, + 'taskID' => $taskA->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); $taskA->refresh(); $this->assertSame($userA->id, $taskA->user_id); @@ -535,8 +532,7 @@ public function testNestedUpsertByIdentifyingColumnDoesNotModifySiblingParentsRe } GRAPHQL; - $this->graphQL( - /** @lang GraphQL */ <<<'GRAPHQL' + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation ($userID: Int!) { updateUser(input: { id: $userID @@ -549,11 +545,9 @@ public function testNestedUpsertByIdentifyingColumnDoesNotModifySiblingParentsRe } } } - GRAPHQL, - [ - 'userID' => $userB->id, - ], - )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + GRAPHQL, [ + 'userID' => $userB->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); $taskA->refresh(); $this->assertSame($userA->id, $taskA->user_id); diff --git a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php index fb26e5c3bb..d6b219e648 100644 --- a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php @@ -355,8 +355,7 @@ public function testNestedUpsertManyByIdDoesNotModifySiblingParentsRelatedModel( } GRAPHQL; - $this->graphQL( - /** @lang GraphQL */ <<<'GRAPHQL' + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation ($userID: Int!, $taskID: Int!) { updateUser(input: { id: $userID @@ -365,12 +364,10 @@ public function testNestedUpsertManyByIdDoesNotModifySiblingParentsRelatedModel( id } } - GRAPHQL, - [ - 'userID' => $userB->id, - 'taskID' => $taskA->id, - ], - )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + GRAPHQL, [ + 'userID' => $userB->id, + 'taskID' => $taskA->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); $taskA->refresh(); $this->assertSame($userA->id, $taskA->user_id); @@ -414,8 +411,7 @@ public function testNestedUpsertManyByIdentifyingColumnDoesNotModifySiblingParen } GRAPHQL; - $this->graphQL( - /** @lang GraphQL */ <<<'GRAPHQL' + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation ($userID: Int!) { updateUser(input: { id: $userID @@ -428,11 +424,9 @@ public function testNestedUpsertManyByIdentifyingColumnDoesNotModifySiblingParen } } } - GRAPHQL, - [ - 'userID' => $userB->id, - ], - )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + GRAPHQL, [ + 'userID' => $userB->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); $taskA->refresh(); $this->assertSame($userA->id, $taskA->user_id); From 4d9640797691abd0dcfeba7d8719c36c539799e4 Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 14:44:25 +0100 Subject: [PATCH 07/31] Address review feedback on nested upsert identifying columns --- CHANGELOG.md | 2 +- src/Schema/Directives/UpsertDirective.php | 2 +- src/Schema/Directives/UpsertManyDirective.php | 2 +- .../Schema/Directives/UpsertDirectiveTest.php | 55 +++++++------------ 4 files changed, 23 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a04258c7f7..362a05f041 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ You can find and compare releases at the [GitHub release page](https://github.co ### Added -- Add support for `identifyingColumns` on `@upsert` and `@upsertMany` https://github.com/nuwave/lighthouse/pull/2426 +- Specify identifying columns on nested mutation upserts with `@upsert` and `@upsertMany` https://github.com/nuwave/lighthouse/pull/2426 ### Changed diff --git a/src/Schema/Directives/UpsertDirective.php b/src/Schema/Directives/UpsertDirective.php index f933c08f60..4c135c0f52 100644 --- a/src/Schema/Directives/UpsertDirective.php +++ b/src/Schema/Directives/UpsertDirective.php @@ -25,7 +25,7 @@ public static function definition(): string Specify the columns by which to upsert the model. This is optional, defaults to the ID or model Key. """ - identifyingColumns: [String!] = [] + identifyingColumns: [String!] """ Specify the name of the relation on the parent model. diff --git a/src/Schema/Directives/UpsertManyDirective.php b/src/Schema/Directives/UpsertManyDirective.php index efe73051c5..b58f92117f 100644 --- a/src/Schema/Directives/UpsertManyDirective.php +++ b/src/Schema/Directives/UpsertManyDirective.php @@ -25,7 +25,7 @@ public static function definition(): string Specify the columns by which to upsert the model. This is optional, defaults to the ID or model Key. """ - identifyingColumns: [String!] = [] + identifyingColumns: [String!] """ Specify the name of the relation on the parent model. diff --git a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php index 35e71e7133..99b426908b 100644 --- a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php @@ -7,7 +7,6 @@ use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use Nuwave\Lighthouse\Schema\TypeRegistry; use Tests\DBTestCase; -use Tests\Utils\Models\Company; use Tests\Utils\Models\Task; use Tests\Utils\Models\User; @@ -273,85 +272,71 @@ public function testDirectUpsertByIdentifyingColumn(): void public function testDirectUpsertByIdentifyingColumns(): void { - $company = factory(Company::class)->make(); - $company->id = 1; - $company->save(); + $user = factory(User::class)->make(); + $user->name = 'bar'; + $user->email = 'foo@te.st'; + $user->password = 'old-password'; + $user->save(); - $this->schema - /** @lang GraphQL */ - .= ' + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' type User { id: ID! email: String! name: String! - company_id: ID! } type Mutation { - upsertUser(name: String!, email: String!, company_id:ID!): User @upsert(identifyingColumns: ["name", "company_id"]) + upsertUser(name: String!, email: String!, password: String!): User @upsert(identifyingColumns: ["name", "email"]) } - '; + GRAPHQL; - $this->graphQL( - /** @lang GraphQL */ - ' + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation { upsertUser( email: "foo@te.st" name: "bar" - company_id: 1 + password: "new-password" ) { name email - company_id } } - ', - )->assertJson([ + GRAPHQL)->assertJson([ 'data' => [ 'upsertUser' => [ 'email' => 'foo@te.st', 'name' => 'bar', - 'company_id' => 1, ], ], ]); + $this->assertSame(1, User::count()); $user = User::firstOrFail(); + $this->assertSame('new-password', $user->password); - $this->assertSame('bar', $user->name); - $this->assertSame('foo@te.st', $user->email); - $this->assertSame(1, $user->company_id); - - $this->graphQL( - /** @lang GraphQL */ - ' + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation { upsertUser( - email: "bar@te.st" + email: "foo@te.st" name: "bar" - company_id: 1 + password: "newer-password" ) { name email - company_id } } - ', - )->assertJson([ + GRAPHQL)->assertJson([ 'data' => [ 'upsertUser' => [ - 'email' => 'bar@te.st', + 'email' => 'foo@te.st', 'name' => 'bar', - 'company_id' => $company->id, ], ], ]); + $this->assertSame(1, User::count()); $user->refresh(); - - $this->assertSame('bar', $user->name); - $this->assertSame('bar@te.st', $user->email); + $this->assertSame('newer-password', $user->password); } public function testDirectUpsertByIdentifyingColumnsRequiresAllConfiguredColumns(): void From 223315d176922dc9d9b9d608f08effe55db3d86b Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 14:58:42 +0100 Subject: [PATCH 08/31] Address remaining upsert review threads --- docs/master/api-reference/directives.md | 8 +- src/Schema/Directives/UpsertDirective.php | 50 +++++++- src/Schema/Directives/UpsertManyDirective.php | 50 +++++++- .../Schema/Directives/UpsertDirectiveTest.php | 108 +++++++++++++++--- .../Directives/UpsertManyDirectiveTest.php | 30 +++++ 5 files changed, 222 insertions(+), 24 deletions(-) diff --git a/docs/master/api-reference/directives.md b/docs/master/api-reference/directives.md index fe05ee6059..273c0de5be 100644 --- a/docs/master/api-reference/directives.md +++ b/docs/master/api-reference/directives.md @@ -4026,9 +4026,9 @@ directive @upsert( """ Specify the columns by which to upsert the model. - This is optional, defaults to the ID or model key. + Optional, by default `id` or the primary key of the model are used. """ - identifyingColumns: [String!] = [] + identifyingColumns: [String!] """ Specify the name of the relation on the parent model. @@ -4075,9 +4075,9 @@ directive @upsertMany( """ Specify the columns by which to upsert the model. - This is optional, defaults to the ID or model key. + Optional, by default `id` or the primary key of the model are used. """ - identifyingColumns: [String!] = [] + identifyingColumns: [String!] """ Specify the name of the relation on the parent model. diff --git a/src/Schema/Directives/UpsertDirective.php b/src/Schema/Directives/UpsertDirective.php index 4c135c0f52..3dbc0d4cfc 100644 --- a/src/Schema/Directives/UpsertDirective.php +++ b/src/Schema/Directives/UpsertDirective.php @@ -2,11 +2,21 @@ namespace Nuwave\Lighthouse\Schema\Directives; +use GraphQL\Language\AST\FieldDefinitionNode; +use GraphQL\Language\AST\InputValueDefinitionNode; +use GraphQL\Language\AST\InputObjectTypeDefinitionNode; +use GraphQL\Language\AST\InterfaceTypeDefinitionNode; +use GraphQL\Language\AST\ObjectTypeDefinitionNode; use Illuminate\Database\Eloquent\Relations\Relation; +use Nuwave\Lighthouse\Exceptions\DefinitionException; use Nuwave\Lighthouse\Execution\Arguments\SaveModel; use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; +use Nuwave\Lighthouse\Schema\AST\DocumentAST; +use Nuwave\Lighthouse\Support\Contracts\ArgManipulator; +use Nuwave\Lighthouse\Support\Contracts\FieldManipulator; +use Nuwave\Lighthouse\Support\Contracts\InputFieldManipulator; -class UpsertDirective extends OneModelMutationDirective +class UpsertDirective extends OneModelMutationDirective implements ArgManipulator, FieldManipulator, InputFieldManipulator { public static function definition(): string { @@ -23,7 +33,7 @@ public static function definition(): string """ Specify the columns by which to upsert the model. - This is optional, defaults to the ID or model Key. + Optional, by default `id` or the primary key of the model are used. """ identifyingColumns: [String!] @@ -45,4 +55,40 @@ protected function makeExecutionFunction(?Relation $parentRelation = null): call $parentRelation, ); } + + public function manipulateArgDefinition( + DocumentAST &$documentAST, + InputValueDefinitionNode &$argDefinition, + FieldDefinitionNode &$parentField, + ObjectTypeDefinitionNode|InterfaceTypeDefinitionNode &$parentType, + ): void { + $this->ensureNonEmptyIdentifyingColumns("{$parentType->name->value}.{$parentField->name->value}:{$argDefinition->name->value}"); + } + + public function manipulateFieldDefinition( + DocumentAST &$documentAST, + FieldDefinitionNode &$fieldDefinition, + ObjectTypeDefinitionNode|InterfaceTypeDefinitionNode &$parentType, + ): void { + $this->ensureNonEmptyIdentifyingColumns("{$parentType->name->value}.{$fieldDefinition->name->value}"); + } + + public function manipulateInputFieldDefinition( + DocumentAST &$documentAST, + InputValueDefinitionNode &$inputField, + InputObjectTypeDefinitionNode &$parentInput, + ): void { + $this->ensureNonEmptyIdentifyingColumns("{$parentInput->name->value}.{$inputField->name->value}"); + } + + protected function ensureNonEmptyIdentifyingColumns(string $location): void + { + $identifyingColumns = $this->directiveArgValue('identifyingColumns'); + + if (! is_array($identifyingColumns) || $identifyingColumns !== []) { + return; + } + + throw new DefinitionException("Must specify non-empty list of columns in `identifyingColumns` argument of `@{$this->name()}` directive on `{$location}`."); + } } diff --git a/src/Schema/Directives/UpsertManyDirective.php b/src/Schema/Directives/UpsertManyDirective.php index b58f92117f..b4deb22d95 100644 --- a/src/Schema/Directives/UpsertManyDirective.php +++ b/src/Schema/Directives/UpsertManyDirective.php @@ -2,11 +2,21 @@ namespace Nuwave\Lighthouse\Schema\Directives; +use GraphQL\Language\AST\FieldDefinitionNode; +use GraphQL\Language\AST\InputValueDefinitionNode; +use GraphQL\Language\AST\InputObjectTypeDefinitionNode; +use GraphQL\Language\AST\InterfaceTypeDefinitionNode; +use GraphQL\Language\AST\ObjectTypeDefinitionNode; use Illuminate\Database\Eloquent\Relations\Relation; +use Nuwave\Lighthouse\Exceptions\DefinitionException; use Nuwave\Lighthouse\Execution\Arguments\SaveModel; use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; +use Nuwave\Lighthouse\Schema\AST\DocumentAST; +use Nuwave\Lighthouse\Support\Contracts\ArgManipulator; +use Nuwave\Lighthouse\Support\Contracts\FieldManipulator; +use Nuwave\Lighthouse\Support\Contracts\InputFieldManipulator; -class UpsertManyDirective extends ManyModelMutationDirective +class UpsertManyDirective extends ManyModelMutationDirective implements ArgManipulator, FieldManipulator, InputFieldManipulator { public static function definition(): string { @@ -23,7 +33,7 @@ public static function definition(): string """ Specify the columns by which to upsert the model. - This is optional, defaults to the ID or model Key. + Optional, by default `id` or the primary key of the model are used. """ identifyingColumns: [String!] @@ -45,4 +55,40 @@ protected function makeExecutionFunction(?Relation $parentRelation = null): call $parentRelation, ); } + + public function manipulateArgDefinition( + DocumentAST &$documentAST, + InputValueDefinitionNode &$argDefinition, + FieldDefinitionNode &$parentField, + ObjectTypeDefinitionNode|InterfaceTypeDefinitionNode &$parentType, + ): void { + $this->ensureNonEmptyIdentifyingColumns("{$parentType->name->value}.{$parentField->name->value}:{$argDefinition->name->value}"); + } + + public function manipulateFieldDefinition( + DocumentAST &$documentAST, + FieldDefinitionNode &$fieldDefinition, + ObjectTypeDefinitionNode|InterfaceTypeDefinitionNode &$parentType, + ): void { + $this->ensureNonEmptyIdentifyingColumns("{$parentType->name->value}.{$fieldDefinition->name->value}"); + } + + public function manipulateInputFieldDefinition( + DocumentAST &$documentAST, + InputValueDefinitionNode &$inputField, + InputObjectTypeDefinitionNode &$parentInput, + ): void { + $this->ensureNonEmptyIdentifyingColumns("{$parentInput->name->value}.{$inputField->name->value}"); + } + + protected function ensureNonEmptyIdentifyingColumns(string $location): void + { + $identifyingColumns = $this->directiveArgValue('identifyingColumns'); + + if (! is_array($identifyingColumns) || $identifyingColumns !== []) { + return; + } + + throw new DefinitionException("Must specify non-empty list of columns in `identifyingColumns` argument of `@{$this->name()}` directive on `{$location}`."); + } } diff --git a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php index 99b426908b..3eb3626e6b 100644 --- a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php @@ -4,6 +4,7 @@ use GraphQL\Type\Definition\Type; use Illuminate\Container\Container; +use Nuwave\Lighthouse\Exceptions\DefinitionException; use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use Nuwave\Lighthouse\Schema\TypeRegistry; use Tests\DBTestCase; @@ -209,6 +210,10 @@ interface IUser public function testDirectUpsertByIdentifyingColumn(): void { + $email = 'foo@te.st'; + $originalName = 'bar'; + $updatedName = 'foo'; + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' type User { id: ID! @@ -222,52 +227,123 @@ public function testDirectUpsertByIdentifyingColumn(): void GRAPHQL; $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { + mutation ($email: String!, $name: String!) { upsertUser( - email: "foo@te.st" - name: "bar" + email: $email + name: $name ) { name email } } - GRAPHQL)->assertJson([ + GRAPHQL, [ + 'email' => $email, + 'name' => $originalName, + ])->assertJson([ 'data' => [ 'upsertUser' => [ - 'email' => 'foo@te.st', - 'name' => 'bar', + 'email' => $email, + 'name' => $originalName, ], ], ]); $user = User::firstOrFail(); - $this->assertSame('bar', $user->name); - $this->assertSame('foo@te.st', $user->email); + $this->assertSame($originalName, $user->name); + $this->assertSame($email, $user->email); $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { + mutation ($email: String!, $name: String!) { upsertUser( - email: "foo@te.st" - name: "foo" + email: $email + name: $name ) { name email } } - GRAPHQL)->assertJson([ + GRAPHQL, [ + 'email' => $email, + 'name' => $updatedName, + ])->assertJson([ 'data' => [ 'upsertUser' => [ - 'email' => 'foo@te.st', - 'name' => 'foo', + 'email' => $email, + 'name' => $updatedName, ], ], ]); $user->refresh(); - $this->assertSame('foo', $user->name); - $this->assertSame('foo@te.st', $user->email); + $this->assertSame($updatedName, $user->name); + $this->assertSame($email, $user->email); + } + + public function testDirectUpsertByIdentifyingColumnsMustNotBeEmpty(): void + { + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + email: String! + name: String! + } + + type Mutation { + upsertUser(name: String!, email: String!): User @upsert(identifyingColumns: []) + } + GRAPHQL; + + $this->expectException(DefinitionException::class); + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + upsertUser( + email: "foo@te.st" + name: "bar" + ) { + id + } + } + GRAPHQL); + } + + public function testNestedUpsertByIdentifyingColumnsMustNotBeEmpty(): void + { + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + updateUser(input: UpdateUserInput! @spread): User @update + } + + type Task { + id: Int + name: String! + } + + type User { + id: Int + tasks: [Task!]! @hasMany + } + + input UpdateUserInput { + id: Int + tasks: [UpdateTaskInput!] @upsert(relation: "tasks", identifyingColumns: []) + } + + input UpdateTaskInput { + id: Int + name: String + } + GRAPHQL; + + $this->expectException(DefinitionException::class); + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + updateUser(input: {id: 1}) { + id + } + } + GRAPHQL); } public function testDirectUpsertByIdentifyingColumns(): void diff --git a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php index d6b219e648..35819d65ad 100644 --- a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php @@ -4,6 +4,7 @@ use GraphQL\Type\Definition\Type; use Illuminate\Container\Container; +use Nuwave\Lighthouse\Exceptions\DefinitionException; use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use Nuwave\Lighthouse\Schema\TypeRegistry; use Tests\DBTestCase; @@ -320,6 +321,35 @@ public function testDirectUpsertManyByIdentifyingColumnsRequiresAllConfiguredCol GRAPHQL)->assertGraphQLErrorMessage(UpsertModel::MISSING_IDENTIFYING_COLUMNS_FOR_UPSERT); } + public function testDirectUpsertManyByIdentifyingColumnsMustNotBeEmpty(): void + { + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + email: String! + name: String! + } + + input UpsertUserInput { + email: String! + name: String! + } + + type Mutation { + upsertUsers(inputs: [UpsertUserInput!]!): [User!]! @upsertMany(identifyingColumns: []) + } + GRAPHQL; + + $this->expectException(DefinitionException::class); + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + upsertUsers(inputs: [{ email: "foo@te.st", name: "bar" }]) { + id + } + } + GRAPHQL); + } + public function testNestedUpsertManyByIdDoesNotModifySiblingParentsRelatedModel(): void { $userA = factory(User::class)->create(); From e9c0e5cabef1b261c6fe0d025add5a0ed6da927c Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 14:00:00 +0000 Subject: [PATCH 09/31] Apply php-cs-fixer changes --- src/Schema/Directives/UpsertDirective.php | 2 +- src/Schema/Directives/UpsertManyDirective.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Schema/Directives/UpsertDirective.php b/src/Schema/Directives/UpsertDirective.php index 3dbc0d4cfc..426815474c 100644 --- a/src/Schema/Directives/UpsertDirective.php +++ b/src/Schema/Directives/UpsertDirective.php @@ -3,8 +3,8 @@ namespace Nuwave\Lighthouse\Schema\Directives; use GraphQL\Language\AST\FieldDefinitionNode; -use GraphQL\Language\AST\InputValueDefinitionNode; use GraphQL\Language\AST\InputObjectTypeDefinitionNode; +use GraphQL\Language\AST\InputValueDefinitionNode; use GraphQL\Language\AST\InterfaceTypeDefinitionNode; use GraphQL\Language\AST\ObjectTypeDefinitionNode; use Illuminate\Database\Eloquent\Relations\Relation; diff --git a/src/Schema/Directives/UpsertManyDirective.php b/src/Schema/Directives/UpsertManyDirective.php index b4deb22d95..d41f5f41aa 100644 --- a/src/Schema/Directives/UpsertManyDirective.php +++ b/src/Schema/Directives/UpsertManyDirective.php @@ -3,8 +3,8 @@ namespace Nuwave\Lighthouse\Schema\Directives; use GraphQL\Language\AST\FieldDefinitionNode; -use GraphQL\Language\AST\InputValueDefinitionNode; use GraphQL\Language\AST\InputObjectTypeDefinitionNode; +use GraphQL\Language\AST\InputValueDefinitionNode; use GraphQL\Language\AST\InterfaceTypeDefinitionNode; use GraphQL\Language\AST\ObjectTypeDefinitionNode; use Illuminate\Database\Eloquent\Relations\Relation; From 403b081564007dcf57e2fca45864c82dea0b66cc Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 15:21:17 +0100 Subject: [PATCH 10/31] review --- src/Execution/Arguments/UpsertModel.php | 14 ++++++++----- .../MutationExecutor/BelongsToManyTest.php | 12 +++++------ .../MutationExecutor/BelongsToTest.php | 10 ++++----- .../MutationExecutor/MorphOneTest.php | 21 ++++++++++++------- 4 files changed, 31 insertions(+), 26 deletions(-) diff --git a/src/Execution/Arguments/UpsertModel.php b/src/Execution/Arguments/UpsertModel.php index b741e4d06b..95aa2c333c 100644 --- a/src/Execution/Arguments/UpsertModel.php +++ b/src/Execution/Arguments/UpsertModel.php @@ -19,12 +19,12 @@ class UpsertModel implements ArgResolver /** * @param callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver $previous - * @param array|null $identifyingColumns */ public function __construct( callable $previous, + /** @var array|null */ protected ?array $identifyingColumns = null, - /** @var \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null $parentRelation */ + /** @var \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null */ protected ?Relation $parentRelation = null, ) { $this->previous = $previous; @@ -43,11 +43,14 @@ public function __invoke($model, $args): mixed $identifyingColumns = $this->identifyingColumnValues($args, $this->identifyingColumns) ?? throw new Error(self::MISSING_IDENTIFYING_COLUMNS_FOR_UPSERT); - $existingModel = $this->queryBuilder($model)->firstWhere($identifyingColumns); + $existingModel = $this->queryBuilder($model) + ->firstWhere($identifyingColumns); if ( $existingModel === null && $this->parentRelation !== null - && $model->newQuery()->where($identifyingColumns)->exists() + && $model->newQuery() + ->where($identifyingColumns) + ->exists() ) { throw new Error(self::CANNOT_UPSERT_UNRELATED_MODEL); } @@ -60,7 +63,8 @@ public function __invoke($model, $args): mixed if ($existingModel === null) { $id = $this->retrieveID($model, $args); if ($id) { - $existingModel = $this->queryBuilder($model)->find($id); + $existingModel = $this->queryBuilder($model) + ->find($id); if ( $existingModel === null && $this->parentRelation !== null diff --git a/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php b/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php index 0581a837d6..0e073bff2d 100644 --- a/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php +++ b/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php @@ -610,14 +610,12 @@ public function testUpdateWithBelongsToMany(string $action): void $role->name = 'is_admin'; $role->save(); - $users = factory(User::class, 2)->create(); - $role->users() - ->attach( - $users, - ); + $user1 = factory(User::class)->create(); + $user2 = factory(User::class)->create(); + $role->users()->attach([$user1, $user2]); - $firstUserID = $users[0]->id; - $secondUserID = $users[1]->id; + $firstUserID = $user1->id; + $secondUserID = $user2->id; $response = $this->graphQL(/** @lang GraphQL */ << $task->id, - 'userID' => $userA->id, - ], - )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + GRAPHQL, [ + 'taskID' => $task->id, + 'userID' => $userA->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); $userA->refresh(); $task->refresh(); diff --git a/tests/Integration/Execution/MutationExecutor/MorphOneTest.php b/tests/Integration/Execution/MutationExecutor/MorphOneTest.php index 89d8e7ad13..b0cbedfbb6 100644 --- a/tests/Integration/Execution/MutationExecutor/MorphOneTest.php +++ b/tests/Integration/Execution/MutationExecutor/MorphOneTest.php @@ -412,10 +412,8 @@ public function testNestedConnectMorphOne(): void $task = factory(Task::class)->create(); $this->assertInstanceOf(Task::class, $task); - $image = factory(Image::class)->make(); + $image = factory(Image::class)->create(); $this->assertInstanceOf(Image::class, $image); - $image->url = 'original'; - $image->save(); $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation ($input: UpdateTaskInput!) { @@ -438,10 +436,17 @@ public function testNestedConnectMorphOne(): void ], ], ], - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); - - $image->refresh(); - $this->assertSame('original', $image->url); - $this->assertNull($image->imageable_id); + ])->assertJson([ + 'data' => [ + 'updateTask' => [ + 'id' => '1', + 'name' => 'foo', + 'image' => [ + 'url' => 'foo', + ], + ], + ], + ]); } + } From c239147d9c35e692547792e85b6dbdf860d9ab8a Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 15:30:50 +0100 Subject: [PATCH 11/31] Revert "review" This reverts commit 0b8eb5a8c767040e3d99b7d7dd3df0490a7d2769. --- src/Execution/Arguments/UpsertModel.php | 14 +++++-------- .../MutationExecutor/BelongsToManyTest.php | 12 ++++++----- .../MutationExecutor/BelongsToTest.php | 10 +++++---- .../MutationExecutor/MorphOneTest.php | 21 +++++++------------ 4 files changed, 26 insertions(+), 31 deletions(-) diff --git a/src/Execution/Arguments/UpsertModel.php b/src/Execution/Arguments/UpsertModel.php index 95aa2c333c..b741e4d06b 100644 --- a/src/Execution/Arguments/UpsertModel.php +++ b/src/Execution/Arguments/UpsertModel.php @@ -19,12 +19,12 @@ class UpsertModel implements ArgResolver /** * @param callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver $previous + * @param array|null $identifyingColumns */ public function __construct( callable $previous, - /** @var array|null */ protected ?array $identifyingColumns = null, - /** @var \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null */ + /** @var \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null $parentRelation */ protected ?Relation $parentRelation = null, ) { $this->previous = $previous; @@ -43,14 +43,11 @@ public function __invoke($model, $args): mixed $identifyingColumns = $this->identifyingColumnValues($args, $this->identifyingColumns) ?? throw new Error(self::MISSING_IDENTIFYING_COLUMNS_FOR_UPSERT); - $existingModel = $this->queryBuilder($model) - ->firstWhere($identifyingColumns); + $existingModel = $this->queryBuilder($model)->firstWhere($identifyingColumns); if ( $existingModel === null && $this->parentRelation !== null - && $model->newQuery() - ->where($identifyingColumns) - ->exists() + && $model->newQuery()->where($identifyingColumns)->exists() ) { throw new Error(self::CANNOT_UPSERT_UNRELATED_MODEL); } @@ -63,8 +60,7 @@ public function __invoke($model, $args): mixed if ($existingModel === null) { $id = $this->retrieveID($model, $args); if ($id) { - $existingModel = $this->queryBuilder($model) - ->find($id); + $existingModel = $this->queryBuilder($model)->find($id); if ( $existingModel === null && $this->parentRelation !== null diff --git a/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php b/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php index 0e073bff2d..0581a837d6 100644 --- a/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php +++ b/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php @@ -610,12 +610,14 @@ public function testUpdateWithBelongsToMany(string $action): void $role->name = 'is_admin'; $role->save(); - $user1 = factory(User::class)->create(); - $user2 = factory(User::class)->create(); - $role->users()->attach([$user1, $user2]); + $users = factory(User::class, 2)->create(); + $role->users() + ->attach( + $users, + ); - $firstUserID = $user1->id; - $secondUserID = $user2->id; + $firstUserID = $users[0]->id; + $secondUserID = $users[1]->id; $response = $this->graphQL(/** @lang GraphQL */ << $task->id, - 'userID' => $userA->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + GRAPHQL, + [ + 'taskID' => $task->id, + 'userID' => $userA->id, + ], + )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); $userA->refresh(); $task->refresh(); diff --git a/tests/Integration/Execution/MutationExecutor/MorphOneTest.php b/tests/Integration/Execution/MutationExecutor/MorphOneTest.php index b0cbedfbb6..89d8e7ad13 100644 --- a/tests/Integration/Execution/MutationExecutor/MorphOneTest.php +++ b/tests/Integration/Execution/MutationExecutor/MorphOneTest.php @@ -412,8 +412,10 @@ public function testNestedConnectMorphOne(): void $task = factory(Task::class)->create(); $this->assertInstanceOf(Task::class, $task); - $image = factory(Image::class)->create(); + $image = factory(Image::class)->make(); $this->assertInstanceOf(Image::class, $image); + $image->url = 'original'; + $image->save(); $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation ($input: UpdateTaskInput!) { @@ -436,17 +438,10 @@ public function testNestedConnectMorphOne(): void ], ], ], - ])->assertJson([ - 'data' => [ - 'updateTask' => [ - 'id' => '1', - 'name' => 'foo', - 'image' => [ - 'url' => 'foo', - ], - ], - ], - ]); - } + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + $image->refresh(); + $this->assertSame('original', $image->url); + $this->assertNull($image->imageable_id); + } } From 37f2fc7fe06c1aff8fbd413eb067c2e9aac88b77 Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 15:30:50 +0100 Subject: [PATCH 12/31] Revert "Scope nested upserts to parent relations and expand coverage" This reverts commit 6e6aa17fec47be98969784bd046dc95ff0b96cb6. --- src/Execution/Arguments/NestedBelongsTo.php | 2 +- src/Execution/Arguments/NestedManyToMany.php | 2 +- src/Execution/Arguments/NestedOneToMany.php | 2 +- src/Execution/Arguments/NestedOneToOne.php | 2 +- src/Execution/Arguments/UpsertModel.php | 8 +-- .../MutationExecutor/BelongsToManyTest.php | 62 ++--------------- .../MutationExecutor/BelongsToTest.php | 69 ++----------------- .../MutationExecutor/HasManyTest.php | 33 --------- .../Execution/MutationExecutor/HasOneTest.php | 33 --------- .../MutationExecutor/MorphManyTest.php | 33 --------- .../MutationExecutor/MorphOneTest.php | 53 ++++---------- .../MutationExecutor/MorphToManyTest.php | 33 --------- .../Schema/Directives/UpsertDirectiveTest.php | 24 ++++--- .../Directives/UpsertManyDirectiveTest.php | 24 ++++--- 14 files changed, 59 insertions(+), 321 deletions(-) diff --git a/src/Execution/Arguments/NestedBelongsTo.php b/src/Execution/Arguments/NestedBelongsTo.php index 06f931ff13..7a446f0840 100644 --- a/src/Execution/Arguments/NestedBelongsTo.php +++ b/src/Execution/Arguments/NestedBelongsTo.php @@ -41,7 +41,7 @@ public function __invoke($model, $args): void } if ($args->has('upsert')) { - $upsertModel = new ResolveNested(new UpsertModel(new SaveModel(), null, $this->relation)); + $upsertModel = new ResolveNested(new UpsertModel(new SaveModel())); $related = $upsertModel( $this->relation->make(), $args->arguments['upsert']->value, diff --git a/src/Execution/Arguments/NestedManyToMany.php b/src/Execution/Arguments/NestedManyToMany.php index ae95f7432b..a2ac78e90b 100644 --- a/src/Execution/Arguments/NestedManyToMany.php +++ b/src/Execution/Arguments/NestedManyToMany.php @@ -52,7 +52,7 @@ public function __invoke($model, $args): void } if ($args->has('upsert')) { - $upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation), null, $relation)); + $upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation))); foreach ($args->arguments['upsert']->value as $childArgs) { // @phpstan-ignore-next-line Relation&Builder mixin not recognized diff --git a/src/Execution/Arguments/NestedOneToMany.php b/src/Execution/Arguments/NestedOneToMany.php index ee120162f6..8b2c216aa1 100644 --- a/src/Execution/Arguments/NestedOneToMany.php +++ b/src/Execution/Arguments/NestedOneToMany.php @@ -40,7 +40,7 @@ public function __invoke($model, $args): void } if ($args->has('upsert')) { - $upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation), null, $relation)); + $upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation))); foreach ($args->arguments['upsert']->value as $childArgs) { // @phpstan-ignore-next-line Relation&Builder mixin not recognized diff --git a/src/Execution/Arguments/NestedOneToOne.php b/src/Execution/Arguments/NestedOneToOne.php index 7c630f4e07..e08d551c5a 100644 --- a/src/Execution/Arguments/NestedOneToOne.php +++ b/src/Execution/Arguments/NestedOneToOne.php @@ -42,7 +42,7 @@ public function __invoke($model, $args): void } if ($args->has('upsert')) { - $upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation), null, $relation)); + $upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation))); $upsertModel($relation->first() ?? $relation->make(), $args->arguments['upsert']->value); } diff --git a/src/Execution/Arguments/UpsertModel.php b/src/Execution/Arguments/UpsertModel.php index b741e4d06b..066475df66 100644 --- a/src/Execution/Arguments/UpsertModel.php +++ b/src/Execution/Arguments/UpsertModel.php @@ -19,7 +19,7 @@ class UpsertModel implements ArgResolver /** * @param callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver $previous - * @param array|null $identifyingColumns + * @param array|null $identifyingColumns */ public function __construct( callable $previous, @@ -78,11 +78,7 @@ public function __invoke($model, $args): mixed return ($this->previous)($model, $args); } - /** - * @param array $identifyingColumns - * - * @return array|null - */ + /** @return array|null */ protected function identifyingColumnValues(ArgumentSet $args, array $identifyingColumns): ?array { $identifyingValues = array_intersect_key( diff --git a/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php b/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php index 0581a837d6..e164f7b50f 100644 --- a/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php +++ b/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php @@ -3,7 +3,6 @@ namespace Tests\Integration\Execution\MutationExecutor; use Faker\Provider\Lorem; -use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\Role; @@ -315,37 +314,6 @@ public function testUpsertBelongsToManyWithoutId(): void $this->assertSame('is_user', $role->name); } - public function testNestedUpsertByIDDoesNotModifyUnrelatedBelongsToManyModel(): void - { - $roleA = factory(Role::class)->create(); - $roleB = factory(Role::class)->create(); - $userA = factory(User::class)->create(); - - $roleA->users()->attach($userA); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation ($roleID: ID!, $userID: ID!) { - upsertRole(input: { - id: $roleID - name: "role-b" - users: { - upsert: [{ id: $userID, name: "hacked" }] - } - }) { - id - } - } - GRAPHQL, [ - 'roleID' => $roleB->id, - 'userID' => $userA->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); - - $userA->refresh(); - $this->assertSame($roleA->id, $userA->roles()->firstOrFail()->id); - $this->assertNotSame('hacked', $userA->name); - $this->assertCount(0, $roleB->users()->get()); - } - public function testCreateAndConnectWithBelongsToMany(): void { $user = factory(User::class)->make(); @@ -610,27 +578,23 @@ public function testUpdateWithBelongsToMany(string $action): void $role->name = 'is_admin'; $role->save(); - $users = factory(User::class, 2)->create(); $role->users() ->attach( - $users, + factory(User::class, 2)->create(), ); - $firstUserID = $users[0]->id; - $secondUserID = $users[1]->id; - - $response = $this->graphQL(/** @lang GraphQL */ <<graphQL(/** @lang GraphQL */ <<assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); - - $role->refresh(); - $this->assertCount(2, $role->users()->get()); - $this->assertSame('is_admin', $role->name); - - return; - } - - $response->assertJson([ + GRAPHQL)->assertJson([ 'data' => [ "{$action}Role" => [ 'id' => '1', 'name' => 'is_user', 'users' => [ [ - 'id' => (string) $firstUserID, + 'id' => '1', 'name' => 'user1', ], [ - 'id' => (string) $secondUserID, + 'id' => '2', 'name' => 'user2', ], ], diff --git a/tests/Integration/Execution/MutationExecutor/BelongsToTest.php b/tests/Integration/Execution/MutationExecutor/BelongsToTest.php index 55df160e8d..660a9f4575 100644 --- a/tests/Integration/Execution/MutationExecutor/BelongsToTest.php +++ b/tests/Integration/Execution/MutationExecutor/BelongsToTest.php @@ -4,7 +4,6 @@ use Illuminate\Database\Events\QueryExecuted; use Illuminate\Support\Facades\DB; -use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\Role; @@ -279,39 +278,6 @@ public function testUpsertWithNewBelongsTo(): void ]); } - public function testNestedUpsertByIDDoesNotModifyUnrelatedBelongsToModel(): void - { - $userA = factory(User::class)->create(); - $userB = factory(User::class)->create(); - $task = factory(Task::class)->create(); - $task->user()->associate($userB); - $task->save(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation ($taskID: ID!, $userID: ID!) { - upsertTask(input: { - id: $taskID - name: "task" - user: { - upsert: { id: $userID, name: "hacked" } - } - }) { - id - } - } - GRAPHQL, - [ - 'taskID' => $task->id, - 'userID' => $userA->id, - ], - )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); - - $userA->refresh(); - $task->refresh(); - $this->assertNotSame('hacked', $userA->name); - $this->assertSame($userB->id, $task->user_id); - } - public function testUpsertBelongsToWithoutID(): void { $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' @@ -641,37 +607,10 @@ public function testSavesOnlyOnceWithMultipleBelongsTo(): void public function testUpsertUsingCreateAndUpdateUsingUpsertBelongsTo(): void { - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - upsertTask(input: { - id: 1 - name: "foo" - user: { - upsert: { - name: "foo-user" - } - } - }) { - id - name - user { - id - name - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'upsertTask' => [ - 'id' => '1', - 'name' => 'foo', - 'user' => [ - 'id' => '1', - 'name' => 'foo-user', - ], - ], - ], - ]); + $user = factory(User::class)->make(); + $this->assertInstanceOf(User::class, $user); + $user->name = 'foo'; + $user->save(); $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation { diff --git a/tests/Integration/Execution/MutationExecutor/HasManyTest.php b/tests/Integration/Execution/MutationExecutor/HasManyTest.php index c3453722d2..bf564e1de9 100644 --- a/tests/Integration/Execution/MutationExecutor/HasManyTest.php +++ b/tests/Integration/Execution/MutationExecutor/HasManyTest.php @@ -2,7 +2,6 @@ namespace Tests\Integration\Execution\MutationExecutor; -use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\CustomPrimaryKey; @@ -316,38 +315,6 @@ public function testCreateUsingUpsertWithNewHasMany(): void ]); } - public function testNestedUpsertByIDDoesNotModifyUnrelatedHasManyModel(): void - { - $userA = factory(User::class)->create(); - $userB = factory(User::class)->create(); - - $taskA = factory(Task::class)->make(); - $taskA->name = 'from-user-a'; - $taskA->user()->associate($userA); - $taskA->save(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation ($userID: ID!, $taskID: ID!) { - upsertUser(input: { - id: $userID - name: "user-b" - tasks: { - upsert: [{ id: $taskID, name: "hacked" }] - } - }) { - id - } - } - GRAPHQL, [ - 'userID' => $userB->id, - 'taskID' => $taskA->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); - - $taskA->refresh(); - $this->assertSame('from-user-a', $taskA->name); - $this->assertSame($userA->id, $taskA->user_id); - } - /** @return iterable */ public static function existingModelMutations(): iterable { diff --git a/tests/Integration/Execution/MutationExecutor/HasOneTest.php b/tests/Integration/Execution/MutationExecutor/HasOneTest.php index 638ea93e7f..b6856e22c7 100644 --- a/tests/Integration/Execution/MutationExecutor/HasOneTest.php +++ b/tests/Integration/Execution/MutationExecutor/HasOneTest.php @@ -2,7 +2,6 @@ namespace Tests\Integration\Execution\MutationExecutor; -use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\Post; @@ -185,38 +184,6 @@ public function testCreateUsingUpsertWithNewHasOne(): void ]); } - public function testNestedUpsertByIDDoesNotModifyUnrelatedHasOneModel(): void - { - $taskA = factory(Task::class)->create(); - $taskB = factory(Task::class)->create(); - - $postA = factory(Post::class)->make(); - $postA->title = 'from-task-a'; - $postA->task()->associate($taskA); - $postA->save(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation ($taskID: ID!, $postID: ID!) { - upsertTask(input: { - id: $taskID - name: "task-b" - post: { - upsert: { id: $postID, title: "hacked" } - } - }) { - id - } - } - GRAPHQL, [ - 'taskID' => $taskB->id, - 'postID' => $postA->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); - - $postA->refresh(); - $this->assertSame('from-task-a', $postA->title); - $this->assertSame($taskA->id, $postA->task_id); - } - public function testUpsertHasOneWithoutID(): void { $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' diff --git a/tests/Integration/Execution/MutationExecutor/MorphManyTest.php b/tests/Integration/Execution/MutationExecutor/MorphManyTest.php index f71292cc83..497cd79de4 100644 --- a/tests/Integration/Execution/MutationExecutor/MorphManyTest.php +++ b/tests/Integration/Execution/MutationExecutor/MorphManyTest.php @@ -2,7 +2,6 @@ namespace Tests\Integration\Execution\MutationExecutor; -use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\Image; @@ -227,38 +226,6 @@ public function testUpsertMorphManyWithoutId(): void ]); } - public function testNestedUpsertByIDDoesNotModifyUnrelatedMorphManyModel(): void - { - $taskA = factory(Task::class)->create(); - $taskB = factory(Task::class)->create(); - - $imageA = factory(Image::class)->make(); - $imageA->url = 'from-task-a'; - $imageA->imageable()->associate($taskA); - $imageA->save(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation ($taskID: ID!, $imageID: ID!) { - upsertTask(input: { - id: $taskID - name: "task-b" - images: { - upsert: [{ id: $imageID, url: "hacked" }] - } - }) { - id - } - } - GRAPHQL, [ - 'taskID' => $taskB->id, - 'imageID' => $imageA->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); - - $imageA->refresh(); - $this->assertSame('from-task-a', $imageA->url); - $this->assertSame($taskA->id, $imageA->imageable_id); - } - public function testAllowsNullOperations(): void { factory(Task::class)->create(); diff --git a/tests/Integration/Execution/MutationExecutor/MorphOneTest.php b/tests/Integration/Execution/MutationExecutor/MorphOneTest.php index 89d8e7ad13..915e287a39 100644 --- a/tests/Integration/Execution/MutationExecutor/MorphOneTest.php +++ b/tests/Integration/Execution/MutationExecutor/MorphOneTest.php @@ -2,7 +2,6 @@ namespace Tests\Integration\Execution\MutationExecutor; -use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\Image; @@ -178,38 +177,6 @@ public function testUpsertMorphOneWithoutId(): void ]); } - public function testNestedUpsertByIDDoesNotModifyUnrelatedMorphOneModel(): void - { - $taskA = factory(Task::class)->create(); - $taskB = factory(Task::class)->create(); - - $imageA = factory(Image::class)->make(); - $imageA->url = 'from-task-a'; - $imageA->imageable()->associate($taskA); - $imageA->save(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation ($taskID: ID!, $imageID: ID!) { - upsertTask(input: { - id: $taskID - name: "task-b" - image: { - upsert: { id: $imageID, url: "hacked" } - } - }) { - id - } - } - GRAPHQL, [ - 'taskID' => $taskB->id, - 'imageID' => $imageA->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); - - $imageA->refresh(); - $this->assertSame('from-task-a', $imageA->url); - $this->assertSame($taskA->id, $imageA->imageable_id); - } - public function testAllowsNullOperations(): void { factory(Task::class)->create(); @@ -412,10 +379,8 @@ public function testNestedConnectMorphOne(): void $task = factory(Task::class)->create(); $this->assertInstanceOf(Task::class, $task); - $image = factory(Image::class)->make(); + $image = factory(Image::class)->create(); $this->assertInstanceOf(Image::class, $image); - $image->url = 'original'; - $image->save(); $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation ($input: UpdateTaskInput!) { @@ -438,10 +403,16 @@ public function testNestedConnectMorphOne(): void ], ], ], - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); - - $image->refresh(); - $this->assertSame('original', $image->url); - $this->assertNull($image->imageable_id); + ])->assertJson([ + 'data' => [ + 'updateTask' => [ + 'id' => '1', + 'name' => 'foo', + 'image' => [ + 'url' => 'foo', + ], + ], + ], + ]); } } diff --git a/tests/Integration/Execution/MutationExecutor/MorphToManyTest.php b/tests/Integration/Execution/MutationExecutor/MorphToManyTest.php index 8e8714946a..2114e23f3b 100644 --- a/tests/Integration/Execution/MutationExecutor/MorphToManyTest.php +++ b/tests/Integration/Execution/MutationExecutor/MorphToManyTest.php @@ -2,10 +2,8 @@ namespace Tests\Integration\Execution\MutationExecutor; -use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use Tests\DBTestCase; use Tests\Utils\Models\Tag; -use Tests\Utils\Models\Task; final class MorphToManyTest extends DBTestCase { @@ -224,37 +222,6 @@ public function testUpsertATaskWithExistingTagsByUsingSync(): void ]); } - public function testNestedUpsertByIDDoesNotModifyUnrelatedMorphToManyModel(): void - { - $taskA = factory(Task::class)->create(); - $taskB = factory(Task::class)->create(); - $tagA = factory(Tag::class)->create(); - - $taskA->tags()->attach($tagA); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation ($taskID: ID!, $tagID: ID!) { - upsertTask(input: { - id: $taskID - name: "task-b" - tags: { - upsert: [{ id: $tagID, name: "hacked" }] - } - }) { - id - } - } - GRAPHQL, [ - 'taskID' => $taskB->id, - 'tagID' => $tagA->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); - - $tagA->refresh(); - $this->assertNotSame('hacked', $tagA->name); - $this->assertCount(1, $taskA->tags()->whereKey($tagA->id)->get()); - $this->assertCount(0, $taskB->tags()->whereKey($tagA->id)->get()); - } - public function testCreateANewTagRelationByUsingCreate(): void { $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' diff --git a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php index 3eb3626e6b..b3849d83c2 100644 --- a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php @@ -536,7 +536,8 @@ public function testNestedUpsertByIdDoesNotModifySiblingParentsRelatedModel(): v } GRAPHQL; - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + $this->graphQL( + /** @lang GraphQL */ <<<'GRAPHQL' mutation ($userID: Int!, $taskID: Int!) { updateUser(input: { id: $userID @@ -545,10 +546,12 @@ public function testNestedUpsertByIdDoesNotModifySiblingParentsRelatedModel(): v id } } - GRAPHQL, [ - 'userID' => $userB->id, - 'taskID' => $taskA->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + GRAPHQL, + [ + 'userID' => $userB->id, + 'taskID' => $taskA->id, + ], + )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); $taskA->refresh(); $this->assertSame($userA->id, $taskA->user_id); @@ -593,7 +596,8 @@ public function testNestedUpsertByIdentifyingColumnDoesNotModifySiblingParentsRe } GRAPHQL; - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + $this->graphQL( + /** @lang GraphQL */ <<<'GRAPHQL' mutation ($userID: Int!) { updateUser(input: { id: $userID @@ -606,9 +610,11 @@ public function testNestedUpsertByIdentifyingColumnDoesNotModifySiblingParentsRe } } } - GRAPHQL, [ - 'userID' => $userB->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + GRAPHQL, + [ + 'userID' => $userB->id, + ], + )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); $taskA->refresh(); $this->assertSame($userA->id, $taskA->user_id); diff --git a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php index 35819d65ad..1bba88b923 100644 --- a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php @@ -385,7 +385,8 @@ public function testNestedUpsertManyByIdDoesNotModifySiblingParentsRelatedModel( } GRAPHQL; - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + $this->graphQL( + /** @lang GraphQL */ <<<'GRAPHQL' mutation ($userID: Int!, $taskID: Int!) { updateUser(input: { id: $userID @@ -394,10 +395,12 @@ public function testNestedUpsertManyByIdDoesNotModifySiblingParentsRelatedModel( id } } - GRAPHQL, [ - 'userID' => $userB->id, - 'taskID' => $taskA->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + GRAPHQL, + [ + 'userID' => $userB->id, + 'taskID' => $taskA->id, + ], + )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); $taskA->refresh(); $this->assertSame($userA->id, $taskA->user_id); @@ -441,7 +444,8 @@ public function testNestedUpsertManyByIdentifyingColumnDoesNotModifySiblingParen } GRAPHQL; - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + $this->graphQL( + /** @lang GraphQL */ <<<'GRAPHQL' mutation ($userID: Int!) { updateUser(input: { id: $userID @@ -454,9 +458,11 @@ public function testNestedUpsertManyByIdentifyingColumnDoesNotModifySiblingParen } } } - GRAPHQL, [ - 'userID' => $userB->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + GRAPHQL, + [ + 'userID' => $userB->id, + ], + )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); $taskA->refresh(); $this->assertSame($userA->id, $taskA->user_id); From 0d741c44369b160e0014844a0af042edb5e4d378 Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 15:32:38 +0100 Subject: [PATCH 13/31] Revert "Revert "Scope nested upserts to parent relations and expand coverage"" This reverts commit 7e1d342afb107f3d490c6f56c8d142e993e75915. --- src/Execution/Arguments/NestedBelongsTo.php | 2 +- src/Execution/Arguments/NestedManyToMany.php | 2 +- src/Execution/Arguments/NestedOneToMany.php | 2 +- src/Execution/Arguments/NestedOneToOne.php | 2 +- src/Execution/Arguments/UpsertModel.php | 8 ++- .../MutationExecutor/BelongsToManyTest.php | 62 +++++++++++++++-- .../MutationExecutor/BelongsToTest.php | 69 +++++++++++++++++-- .../MutationExecutor/HasManyTest.php | 33 +++++++++ .../Execution/MutationExecutor/HasOneTest.php | 33 +++++++++ .../MutationExecutor/MorphManyTest.php | 33 +++++++++ .../MutationExecutor/MorphOneTest.php | 53 ++++++++++---- .../MutationExecutor/MorphToManyTest.php | 33 +++++++++ .../Schema/Directives/UpsertDirectiveTest.php | 24 +++---- .../Directives/UpsertManyDirectiveTest.php | 24 +++---- 14 files changed, 321 insertions(+), 59 deletions(-) diff --git a/src/Execution/Arguments/NestedBelongsTo.php b/src/Execution/Arguments/NestedBelongsTo.php index 7a446f0840..06f931ff13 100644 --- a/src/Execution/Arguments/NestedBelongsTo.php +++ b/src/Execution/Arguments/NestedBelongsTo.php @@ -41,7 +41,7 @@ public function __invoke($model, $args): void } if ($args->has('upsert')) { - $upsertModel = new ResolveNested(new UpsertModel(new SaveModel())); + $upsertModel = new ResolveNested(new UpsertModel(new SaveModel(), null, $this->relation)); $related = $upsertModel( $this->relation->make(), $args->arguments['upsert']->value, diff --git a/src/Execution/Arguments/NestedManyToMany.php b/src/Execution/Arguments/NestedManyToMany.php index a2ac78e90b..ae95f7432b 100644 --- a/src/Execution/Arguments/NestedManyToMany.php +++ b/src/Execution/Arguments/NestedManyToMany.php @@ -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), null, $relation)); foreach ($args->arguments['upsert']->value as $childArgs) { // @phpstan-ignore-next-line Relation&Builder mixin not recognized diff --git a/src/Execution/Arguments/NestedOneToMany.php b/src/Execution/Arguments/NestedOneToMany.php index 8b2c216aa1..ee120162f6 100644 --- a/src/Execution/Arguments/NestedOneToMany.php +++ b/src/Execution/Arguments/NestedOneToMany.php @@ -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), null, $relation)); foreach ($args->arguments['upsert']->value as $childArgs) { // @phpstan-ignore-next-line Relation&Builder mixin not recognized diff --git a/src/Execution/Arguments/NestedOneToOne.php b/src/Execution/Arguments/NestedOneToOne.php index e08d551c5a..7c630f4e07 100644 --- a/src/Execution/Arguments/NestedOneToOne.php +++ b/src/Execution/Arguments/NestedOneToOne.php @@ -42,7 +42,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), null, $relation)); $upsertModel($relation->first() ?? $relation->make(), $args->arguments['upsert']->value); } diff --git a/src/Execution/Arguments/UpsertModel.php b/src/Execution/Arguments/UpsertModel.php index 066475df66..b741e4d06b 100644 --- a/src/Execution/Arguments/UpsertModel.php +++ b/src/Execution/Arguments/UpsertModel.php @@ -19,7 +19,7 @@ class UpsertModel implements ArgResolver /** * @param callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver $previous - * @param array|null $identifyingColumns + * @param array|null $identifyingColumns */ public function __construct( callable $previous, @@ -78,7 +78,11 @@ public function __invoke($model, $args): mixed return ($this->previous)($model, $args); } - /** @return array|null */ + /** + * @param array $identifyingColumns + * + * @return array|null + */ protected function identifyingColumnValues(ArgumentSet $args, array $identifyingColumns): ?array { $identifyingValues = array_intersect_key( diff --git a/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php b/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php index e164f7b50f..0581a837d6 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\UpsertModel; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\Role; @@ -314,6 +315,37 @@ public function testUpsertBelongsToManyWithoutId(): void $this->assertSame('is_user', $role->name); } + public function testNestedUpsertByIDDoesNotModifyUnrelatedBelongsToManyModel(): void + { + $roleA = factory(Role::class)->create(); + $roleB = factory(Role::class)->create(); + $userA = factory(User::class)->create(); + + $roleA->users()->attach($userA); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($roleID: ID!, $userID: ID!) { + upsertRole(input: { + id: $roleID + name: "role-b" + users: { + upsert: [{ id: $userID, name: "hacked" }] + } + }) { + id + } + } + GRAPHQL, [ + 'roleID' => $roleB->id, + 'userID' => $userA->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + + $userA->refresh(); + $this->assertSame($roleA->id, $userA->roles()->firstOrFail()->id); + $this->assertNotSame('hacked', $userA->name); + $this->assertCount(0, $roleB->users()->get()); + } + public function testCreateAndConnectWithBelongsToMany(): void { $user = factory(User::class)->make(); @@ -578,23 +610,27 @@ public function testUpdateWithBelongsToMany(string $action): void $role->name = 'is_admin'; $role->save(); + $users = factory(User::class, 2)->create(); $role->users() ->attach( - factory(User::class, 2)->create(), + $users, ); - $this->graphQL(/** @lang GraphQL */ <<id; + $secondUserID = $users[1]->id; + + $response = $this->graphQL(/** @lang GraphQL */ <<assertJson([ + GRAPHQL); + + if ($action === 'upsert') { + $response->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + + $role->refresh(); + $this->assertCount(2, $role->users()->get()); + $this->assertSame('is_admin', $role->name); + + return; + } + + $response->assertJson([ 'data' => [ "{$action}Role" => [ 'id' => '1', 'name' => 'is_user', 'users' => [ [ - 'id' => '1', + 'id' => (string) $firstUserID, 'name' => 'user1', ], [ - 'id' => '2', + 'id' => (string) $secondUserID, 'name' => 'user2', ], ], diff --git a/tests/Integration/Execution/MutationExecutor/BelongsToTest.php b/tests/Integration/Execution/MutationExecutor/BelongsToTest.php index 660a9f4575..55df160e8d 100644 --- a/tests/Integration/Execution/MutationExecutor/BelongsToTest.php +++ b/tests/Integration/Execution/MutationExecutor/BelongsToTest.php @@ -4,6 +4,7 @@ use Illuminate\Database\Events\QueryExecuted; use Illuminate\Support\Facades\DB; +use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\Role; @@ -278,6 +279,39 @@ public function testUpsertWithNewBelongsTo(): void ]); } + public function testNestedUpsertByIDDoesNotModifyUnrelatedBelongsToModel(): void + { + $userA = factory(User::class)->create(); + $userB = factory(User::class)->create(); + $task = factory(Task::class)->create(); + $task->user()->associate($userB); + $task->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($taskID: ID!, $userID: ID!) { + upsertTask(input: { + id: $taskID + name: "task" + user: { + upsert: { id: $userID, name: "hacked" } + } + }) { + id + } + } + GRAPHQL, + [ + 'taskID' => $task->id, + 'userID' => $userA->id, + ], + )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + + $userA->refresh(); + $task->refresh(); + $this->assertNotSame('hacked', $userA->name); + $this->assertSame($userB->id, $task->user_id); + } + public function testUpsertBelongsToWithoutID(): void { $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' @@ -607,10 +641,37 @@ public function testSavesOnlyOnceWithMultipleBelongsTo(): void public function testUpsertUsingCreateAndUpdateUsingUpsertBelongsTo(): void { - $user = factory(User::class)->make(); - $this->assertInstanceOf(User::class, $user); - $user->name = 'foo'; - $user->save(); + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + upsertTask(input: { + id: 1 + name: "foo" + user: { + upsert: { + name: "foo-user" + } + } + }) { + id + name + user { + id + name + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'upsertTask' => [ + 'id' => '1', + 'name' => 'foo', + 'user' => [ + 'id' => '1', + 'name' => 'foo-user', + ], + ], + ], + ]); $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation { diff --git a/tests/Integration/Execution/MutationExecutor/HasManyTest.php b/tests/Integration/Execution/MutationExecutor/HasManyTest.php index bf564e1de9..c3453722d2 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\UpsertModel; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\CustomPrimaryKey; @@ -315,6 +316,38 @@ public function testCreateUsingUpsertWithNewHasMany(): void ]); } + public function testNestedUpsertByIDDoesNotModifyUnrelatedHasManyModel(): void + { + $userA = factory(User::class)->create(); + $userB = factory(User::class)->create(); + + $taskA = factory(Task::class)->make(); + $taskA->name = 'from-user-a'; + $taskA->user()->associate($userA); + $taskA->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($userID: ID!, $taskID: ID!) { + upsertUser(input: { + id: $userID + name: "user-b" + tasks: { + upsert: [{ id: $taskID, name: "hacked" }] + } + }) { + id + } + } + GRAPHQL, [ + 'userID' => $userB->id, + 'taskID' => $taskA->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + + $taskA->refresh(); + $this->assertSame('from-user-a', $taskA->name); + $this->assertSame($userA->id, $taskA->user_id); + } + /** @return iterable */ public static function existingModelMutations(): iterable { diff --git a/tests/Integration/Execution/MutationExecutor/HasOneTest.php b/tests/Integration/Execution/MutationExecutor/HasOneTest.php index b6856e22c7..638ea93e7f 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\UpsertModel; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\Post; @@ -184,6 +185,38 @@ public function testCreateUsingUpsertWithNewHasOne(): void ]); } + public function testNestedUpsertByIDDoesNotModifyUnrelatedHasOneModel(): void + { + $taskA = factory(Task::class)->create(); + $taskB = factory(Task::class)->create(); + + $postA = factory(Post::class)->make(); + $postA->title = 'from-task-a'; + $postA->task()->associate($taskA); + $postA->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($taskID: ID!, $postID: ID!) { + upsertTask(input: { + id: $taskID + name: "task-b" + post: { + upsert: { id: $postID, title: "hacked" } + } + }) { + id + } + } + GRAPHQL, [ + 'taskID' => $taskB->id, + 'postID' => $postA->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + + $postA->refresh(); + $this->assertSame('from-task-a', $postA->title); + $this->assertSame($taskA->id, $postA->task_id); + } + public function testUpsertHasOneWithoutID(): void { $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' diff --git a/tests/Integration/Execution/MutationExecutor/MorphManyTest.php b/tests/Integration/Execution/MutationExecutor/MorphManyTest.php index 497cd79de4..f71292cc83 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\UpsertModel; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\Image; @@ -226,6 +227,38 @@ public function testUpsertMorphManyWithoutId(): void ]); } + public function testNestedUpsertByIDDoesNotModifyUnrelatedMorphManyModel(): void + { + $taskA = factory(Task::class)->create(); + $taskB = factory(Task::class)->create(); + + $imageA = factory(Image::class)->make(); + $imageA->url = 'from-task-a'; + $imageA->imageable()->associate($taskA); + $imageA->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($taskID: ID!, $imageID: ID!) { + upsertTask(input: { + id: $taskID + name: "task-b" + images: { + upsert: [{ id: $imageID, url: "hacked" }] + } + }) { + id + } + } + GRAPHQL, [ + 'taskID' => $taskB->id, + 'imageID' => $imageA->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + + $imageA->refresh(); + $this->assertSame('from-task-a', $imageA->url); + $this->assertSame($taskA->id, $imageA->imageable_id); + } + public function testAllowsNullOperations(): void { factory(Task::class)->create(); diff --git a/tests/Integration/Execution/MutationExecutor/MorphOneTest.php b/tests/Integration/Execution/MutationExecutor/MorphOneTest.php index 915e287a39..89d8e7ad13 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\UpsertModel; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\Image; @@ -177,6 +178,38 @@ public function testUpsertMorphOneWithoutId(): void ]); } + public function testNestedUpsertByIDDoesNotModifyUnrelatedMorphOneModel(): void + { + $taskA = factory(Task::class)->create(); + $taskB = factory(Task::class)->create(); + + $imageA = factory(Image::class)->make(); + $imageA->url = 'from-task-a'; + $imageA->imageable()->associate($taskA); + $imageA->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($taskID: ID!, $imageID: ID!) { + upsertTask(input: { + id: $taskID + name: "task-b" + image: { + upsert: { id: $imageID, url: "hacked" } + } + }) { + id + } + } + GRAPHQL, [ + 'taskID' => $taskB->id, + 'imageID' => $imageA->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + + $imageA->refresh(); + $this->assertSame('from-task-a', $imageA->url); + $this->assertSame($taskA->id, $imageA->imageable_id); + } + public function testAllowsNullOperations(): void { factory(Task::class)->create(); @@ -379,8 +412,10 @@ public function testNestedConnectMorphOne(): void $task = factory(Task::class)->create(); $this->assertInstanceOf(Task::class, $task); - $image = factory(Image::class)->create(); + $image = factory(Image::class)->make(); $this->assertInstanceOf(Image::class, $image); + $image->url = 'original'; + $image->save(); $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation ($input: UpdateTaskInput!) { @@ -403,16 +438,10 @@ public function testNestedConnectMorphOne(): void ], ], ], - ])->assertJson([ - 'data' => [ - 'updateTask' => [ - 'id' => '1', - 'name' => 'foo', - 'image' => [ - 'url' => 'foo', - ], - ], - ], - ]); + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + + $image->refresh(); + $this->assertSame('original', $image->url); + $this->assertNull($image->imageable_id); } } diff --git a/tests/Integration/Execution/MutationExecutor/MorphToManyTest.php b/tests/Integration/Execution/MutationExecutor/MorphToManyTest.php index 2114e23f3b..8e8714946a 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\UpsertModel; use Tests\DBTestCase; use Tests\Utils\Models\Tag; +use Tests\Utils\Models\Task; final class MorphToManyTest extends DBTestCase { @@ -222,6 +224,37 @@ public function testUpsertATaskWithExistingTagsByUsingSync(): void ]); } + public function testNestedUpsertByIDDoesNotModifyUnrelatedMorphToManyModel(): void + { + $taskA = factory(Task::class)->create(); + $taskB = factory(Task::class)->create(); + $tagA = factory(Tag::class)->create(); + + $taskA->tags()->attach($tagA); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($taskID: ID!, $tagID: ID!) { + upsertTask(input: { + id: $taskID + name: "task-b" + tags: { + upsert: [{ id: $tagID, name: "hacked" }] + } + }) { + id + } + } + GRAPHQL, [ + 'taskID' => $taskB->id, + 'tagID' => $tagA->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + + $tagA->refresh(); + $this->assertNotSame('hacked', $tagA->name); + $this->assertCount(1, $taskA->tags()->whereKey($tagA->id)->get()); + $this->assertCount(0, $taskB->tags()->whereKey($tagA->id)->get()); + } + public function testCreateANewTagRelationByUsingCreate(): void { $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' diff --git a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php index b3849d83c2..3eb3626e6b 100644 --- a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php @@ -536,8 +536,7 @@ public function testNestedUpsertByIdDoesNotModifySiblingParentsRelatedModel(): v } GRAPHQL; - $this->graphQL( - /** @lang GraphQL */ <<<'GRAPHQL' + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation ($userID: Int!, $taskID: Int!) { updateUser(input: { id: $userID @@ -546,12 +545,10 @@ public function testNestedUpsertByIdDoesNotModifySiblingParentsRelatedModel(): v id } } - GRAPHQL, - [ - 'userID' => $userB->id, - 'taskID' => $taskA->id, - ], - )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + GRAPHQL, [ + 'userID' => $userB->id, + 'taskID' => $taskA->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); $taskA->refresh(); $this->assertSame($userA->id, $taskA->user_id); @@ -596,8 +593,7 @@ public function testNestedUpsertByIdentifyingColumnDoesNotModifySiblingParentsRe } GRAPHQL; - $this->graphQL( - /** @lang GraphQL */ <<<'GRAPHQL' + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation ($userID: Int!) { updateUser(input: { id: $userID @@ -610,11 +606,9 @@ public function testNestedUpsertByIdentifyingColumnDoesNotModifySiblingParentsRe } } } - GRAPHQL, - [ - 'userID' => $userB->id, - ], - )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + GRAPHQL, [ + 'userID' => $userB->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); $taskA->refresh(); $this->assertSame($userA->id, $taskA->user_id); diff --git a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php index 1bba88b923..35819d65ad 100644 --- a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php @@ -385,8 +385,7 @@ public function testNestedUpsertManyByIdDoesNotModifySiblingParentsRelatedModel( } GRAPHQL; - $this->graphQL( - /** @lang GraphQL */ <<<'GRAPHQL' + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation ($userID: Int!, $taskID: Int!) { updateUser(input: { id: $userID @@ -395,12 +394,10 @@ public function testNestedUpsertManyByIdDoesNotModifySiblingParentsRelatedModel( id } } - GRAPHQL, - [ - 'userID' => $userB->id, - 'taskID' => $taskA->id, - ], - )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + GRAPHQL, [ + 'userID' => $userB->id, + 'taskID' => $taskA->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); $taskA->refresh(); $this->assertSame($userA->id, $taskA->user_id); @@ -444,8 +441,7 @@ public function testNestedUpsertManyByIdentifyingColumnDoesNotModifySiblingParen } GRAPHQL; - $this->graphQL( - /** @lang GraphQL */ <<<'GRAPHQL' + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation ($userID: Int!) { updateUser(input: { id: $userID @@ -458,11 +454,9 @@ public function testNestedUpsertManyByIdentifyingColumnDoesNotModifySiblingParen } } } - GRAPHQL, - [ - 'userID' => $userB->id, - ], - )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + GRAPHQL, [ + 'userID' => $userB->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); $taskA->refresh(); $this->assertSame($userA->id, $taskA->user_id); From 61932b4f4dfe1547c7443fc1f9db665c86ea8131 Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 15:32:38 +0100 Subject: [PATCH 14/31] Revert "Revert "review"" This reverts commit 879f638805c467214ccb077466e470a1cdb9c58e. --- src/Execution/Arguments/UpsertModel.php | 14 ++++++++----- .../MutationExecutor/BelongsToManyTest.php | 12 +++++------ .../MutationExecutor/BelongsToTest.php | 10 ++++----- .../MutationExecutor/MorphOneTest.php | 21 ++++++++++++------- 4 files changed, 31 insertions(+), 26 deletions(-) diff --git a/src/Execution/Arguments/UpsertModel.php b/src/Execution/Arguments/UpsertModel.php index b741e4d06b..95aa2c333c 100644 --- a/src/Execution/Arguments/UpsertModel.php +++ b/src/Execution/Arguments/UpsertModel.php @@ -19,12 +19,12 @@ class UpsertModel implements ArgResolver /** * @param callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver $previous - * @param array|null $identifyingColumns */ public function __construct( callable $previous, + /** @var array|null */ protected ?array $identifyingColumns = null, - /** @var \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null $parentRelation */ + /** @var \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null */ protected ?Relation $parentRelation = null, ) { $this->previous = $previous; @@ -43,11 +43,14 @@ public function __invoke($model, $args): mixed $identifyingColumns = $this->identifyingColumnValues($args, $this->identifyingColumns) ?? throw new Error(self::MISSING_IDENTIFYING_COLUMNS_FOR_UPSERT); - $existingModel = $this->queryBuilder($model)->firstWhere($identifyingColumns); + $existingModel = $this->queryBuilder($model) + ->firstWhere($identifyingColumns); if ( $existingModel === null && $this->parentRelation !== null - && $model->newQuery()->where($identifyingColumns)->exists() + && $model->newQuery() + ->where($identifyingColumns) + ->exists() ) { throw new Error(self::CANNOT_UPSERT_UNRELATED_MODEL); } @@ -60,7 +63,8 @@ public function __invoke($model, $args): mixed if ($existingModel === null) { $id = $this->retrieveID($model, $args); if ($id) { - $existingModel = $this->queryBuilder($model)->find($id); + $existingModel = $this->queryBuilder($model) + ->find($id); if ( $existingModel === null && $this->parentRelation !== null diff --git a/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php b/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php index 0581a837d6..0e073bff2d 100644 --- a/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php +++ b/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php @@ -610,14 +610,12 @@ public function testUpdateWithBelongsToMany(string $action): void $role->name = 'is_admin'; $role->save(); - $users = factory(User::class, 2)->create(); - $role->users() - ->attach( - $users, - ); + $user1 = factory(User::class)->create(); + $user2 = factory(User::class)->create(); + $role->users()->attach([$user1, $user2]); - $firstUserID = $users[0]->id; - $secondUserID = $users[1]->id; + $firstUserID = $user1->id; + $secondUserID = $user2->id; $response = $this->graphQL(/** @lang GraphQL */ << $task->id, - 'userID' => $userA->id, - ], - )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + GRAPHQL, [ + 'taskID' => $task->id, + 'userID' => $userA->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); $userA->refresh(); $task->refresh(); diff --git a/tests/Integration/Execution/MutationExecutor/MorphOneTest.php b/tests/Integration/Execution/MutationExecutor/MorphOneTest.php index 89d8e7ad13..b0cbedfbb6 100644 --- a/tests/Integration/Execution/MutationExecutor/MorphOneTest.php +++ b/tests/Integration/Execution/MutationExecutor/MorphOneTest.php @@ -412,10 +412,8 @@ public function testNestedConnectMorphOne(): void $task = factory(Task::class)->create(); $this->assertInstanceOf(Task::class, $task); - $image = factory(Image::class)->make(); + $image = factory(Image::class)->create(); $this->assertInstanceOf(Image::class, $image); - $image->url = 'original'; - $image->save(); $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation ($input: UpdateTaskInput!) { @@ -438,10 +436,17 @@ public function testNestedConnectMorphOne(): void ], ], ], - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); - - $image->refresh(); - $this->assertSame('original', $image->url); - $this->assertNull($image->imageable_id); + ])->assertJson([ + 'data' => [ + 'updateTask' => [ + 'id' => '1', + 'name' => 'foo', + 'image' => [ + 'url' => 'foo', + ], + ], + ], + ]); } + } From e3333449dec218b1243424c17b9147f7557bcdb0 Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 15:33:27 +0100 Subject: [PATCH 15/31] Move nested relation-scope changes out of PR #2426 --- src/Execution/Arguments/NestedBelongsTo.php | 2 +- src/Execution/Arguments/NestedManyToMany.php | 2 +- src/Execution/Arguments/NestedOneToMany.php | 2 +- src/Execution/Arguments/NestedOneToOne.php | 2 +- src/Execution/Arguments/UpsertModel.php | 20 +- .../MutationExecutor/BelongsToManyTest.php | 66 +------ .../MutationExecutor/BelongsToTest.php | 67 +------ .../MutationExecutor/HasManyTest.php | 33 ---- .../Execution/MutationExecutor/HasOneTest.php | 33 ---- .../MutationExecutor/MorphManyTest.php | 33 ---- .../MutationExecutor/MorphOneTest.php | 34 ---- .../MutationExecutor/MorphToManyTest.php | 33 ---- .../Schema/Directives/UpsertDirectiveTest.php | 187 +++++++----------- .../Directives/UpsertManyDirectiveTest.php | 54 ++--- 14 files changed, 105 insertions(+), 463 deletions(-) diff --git a/src/Execution/Arguments/NestedBelongsTo.php b/src/Execution/Arguments/NestedBelongsTo.php index 06f931ff13..7a446f0840 100644 --- a/src/Execution/Arguments/NestedBelongsTo.php +++ b/src/Execution/Arguments/NestedBelongsTo.php @@ -41,7 +41,7 @@ public function __invoke($model, $args): void } if ($args->has('upsert')) { - $upsertModel = new ResolveNested(new UpsertModel(new SaveModel(), null, $this->relation)); + $upsertModel = new ResolveNested(new UpsertModel(new SaveModel())); $related = $upsertModel( $this->relation->make(), $args->arguments['upsert']->value, diff --git a/src/Execution/Arguments/NestedManyToMany.php b/src/Execution/Arguments/NestedManyToMany.php index ae95f7432b..a2ac78e90b 100644 --- a/src/Execution/Arguments/NestedManyToMany.php +++ b/src/Execution/Arguments/NestedManyToMany.php @@ -52,7 +52,7 @@ public function __invoke($model, $args): void } if ($args->has('upsert')) { - $upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation), null, $relation)); + $upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation))); foreach ($args->arguments['upsert']->value as $childArgs) { // @phpstan-ignore-next-line Relation&Builder mixin not recognized diff --git a/src/Execution/Arguments/NestedOneToMany.php b/src/Execution/Arguments/NestedOneToMany.php index ee120162f6..8b2c216aa1 100644 --- a/src/Execution/Arguments/NestedOneToMany.php +++ b/src/Execution/Arguments/NestedOneToMany.php @@ -40,7 +40,7 @@ public function __invoke($model, $args): void } if ($args->has('upsert')) { - $upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation), null, $relation)); + $upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation))); foreach ($args->arguments['upsert']->value as $childArgs) { // @phpstan-ignore-next-line Relation&Builder mixin not recognized diff --git a/src/Execution/Arguments/NestedOneToOne.php b/src/Execution/Arguments/NestedOneToOne.php index 7c630f4e07..e08d551c5a 100644 --- a/src/Execution/Arguments/NestedOneToOne.php +++ b/src/Execution/Arguments/NestedOneToOne.php @@ -42,7 +42,7 @@ public function __invoke($model, $args): void } if ($args->has('upsert')) { - $upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation), null, $relation)); + $upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation))); $upsertModel($relation->first() ?? $relation->make(), $args->arguments['upsert']->value); } diff --git a/src/Execution/Arguments/UpsertModel.php b/src/Execution/Arguments/UpsertModel.php index 95aa2c333c..066475df66 100644 --- a/src/Execution/Arguments/UpsertModel.php +++ b/src/Execution/Arguments/UpsertModel.php @@ -19,12 +19,12 @@ class UpsertModel implements ArgResolver /** * @param callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver $previous + * @param array|null $identifyingColumns */ public function __construct( callable $previous, - /** @var array|null */ protected ?array $identifyingColumns = null, - /** @var \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null */ + /** @var \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null $parentRelation */ protected ?Relation $parentRelation = null, ) { $this->previous = $previous; @@ -43,14 +43,11 @@ public function __invoke($model, $args): mixed $identifyingColumns = $this->identifyingColumnValues($args, $this->identifyingColumns) ?? throw new Error(self::MISSING_IDENTIFYING_COLUMNS_FOR_UPSERT); - $existingModel = $this->queryBuilder($model) - ->firstWhere($identifyingColumns); + $existingModel = $this->queryBuilder($model)->firstWhere($identifyingColumns); if ( $existingModel === null && $this->parentRelation !== null - && $model->newQuery() - ->where($identifyingColumns) - ->exists() + && $model->newQuery()->where($identifyingColumns)->exists() ) { throw new Error(self::CANNOT_UPSERT_UNRELATED_MODEL); } @@ -63,8 +60,7 @@ public function __invoke($model, $args): mixed if ($existingModel === null) { $id = $this->retrieveID($model, $args); if ($id) { - $existingModel = $this->queryBuilder($model) - ->find($id); + $existingModel = $this->queryBuilder($model)->find($id); if ( $existingModel === null && $this->parentRelation !== null @@ -82,11 +78,7 @@ public function __invoke($model, $args): mixed return ($this->previous)($model, $args); } - /** - * @param array $identifyingColumns - * - * @return array|null - */ + /** @return array|null */ protected function identifyingColumnValues(ArgumentSet $args, array $identifyingColumns): ?array { $identifyingValues = array_intersect_key( diff --git a/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php b/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php index 0e073bff2d..e164f7b50f 100644 --- a/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php +++ b/tests/Integration/Execution/MutationExecutor/BelongsToManyTest.php @@ -3,7 +3,6 @@ namespace Tests\Integration\Execution\MutationExecutor; use Faker\Provider\Lorem; -use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\Role; @@ -315,37 +314,6 @@ public function testUpsertBelongsToManyWithoutId(): void $this->assertSame('is_user', $role->name); } - public function testNestedUpsertByIDDoesNotModifyUnrelatedBelongsToManyModel(): void - { - $roleA = factory(Role::class)->create(); - $roleB = factory(Role::class)->create(); - $userA = factory(User::class)->create(); - - $roleA->users()->attach($userA); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation ($roleID: ID!, $userID: ID!) { - upsertRole(input: { - id: $roleID - name: "role-b" - users: { - upsert: [{ id: $userID, name: "hacked" }] - } - }) { - id - } - } - GRAPHQL, [ - 'roleID' => $roleB->id, - 'userID' => $userA->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); - - $userA->refresh(); - $this->assertSame($roleA->id, $userA->roles()->firstOrFail()->id); - $this->assertNotSame('hacked', $userA->name); - $this->assertCount(0, $roleB->users()->get()); - } - public function testCreateAndConnectWithBelongsToMany(): void { $user = factory(User::class)->make(); @@ -610,25 +578,23 @@ public function testUpdateWithBelongsToMany(string $action): void $role->name = 'is_admin'; $role->save(); - $user1 = factory(User::class)->create(); - $user2 = factory(User::class)->create(); - $role->users()->attach([$user1, $user2]); - - $firstUserID = $user1->id; - $secondUserID = $user2->id; + $role->users() + ->attach( + factory(User::class, 2)->create(), + ); - $response = $this->graphQL(/** @lang GraphQL */ <<graphQL(/** @lang GraphQL */ <<assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); - - $role->refresh(); - $this->assertCount(2, $role->users()->get()); - $this->assertSame('is_admin', $role->name); - - return; - } - - $response->assertJson([ + GRAPHQL)->assertJson([ 'data' => [ "{$action}Role" => [ 'id' => '1', 'name' => 'is_user', 'users' => [ [ - 'id' => (string) $firstUserID, + 'id' => '1', 'name' => 'user1', ], [ - 'id' => (string) $secondUserID, + 'id' => '2', 'name' => 'user2', ], ], diff --git a/tests/Integration/Execution/MutationExecutor/BelongsToTest.php b/tests/Integration/Execution/MutationExecutor/BelongsToTest.php index c3b62e90e3..660a9f4575 100644 --- a/tests/Integration/Execution/MutationExecutor/BelongsToTest.php +++ b/tests/Integration/Execution/MutationExecutor/BelongsToTest.php @@ -4,7 +4,6 @@ use Illuminate\Database\Events\QueryExecuted; use Illuminate\Support\Facades\DB; -use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\Role; @@ -279,37 +278,6 @@ public function testUpsertWithNewBelongsTo(): void ]); } - public function testNestedUpsertByIDDoesNotModifyUnrelatedBelongsToModel(): void - { - $userA = factory(User::class)->create(); - $userB = factory(User::class)->create(); - $task = factory(Task::class)->create(); - $task->user()->associate($userB); - $task->save(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation ($taskID: ID!, $userID: ID!) { - upsertTask(input: { - id: $taskID - name: "task" - user: { - upsert: { id: $userID, name: "hacked" } - } - }) { - id - } - } - GRAPHQL, [ - 'taskID' => $task->id, - 'userID' => $userA->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); - - $userA->refresh(); - $task->refresh(); - $this->assertNotSame('hacked', $userA->name); - $this->assertSame($userB->id, $task->user_id); - } - public function testUpsertBelongsToWithoutID(): void { $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' @@ -639,37 +607,10 @@ public function testSavesOnlyOnceWithMultipleBelongsTo(): void public function testUpsertUsingCreateAndUpdateUsingUpsertBelongsTo(): void { - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - upsertTask(input: { - id: 1 - name: "foo" - user: { - upsert: { - name: "foo-user" - } - } - }) { - id - name - user { - id - name - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'upsertTask' => [ - 'id' => '1', - 'name' => 'foo', - 'user' => [ - 'id' => '1', - 'name' => 'foo-user', - ], - ], - ], - ]); + $user = factory(User::class)->make(); + $this->assertInstanceOf(User::class, $user); + $user->name = 'foo'; + $user->save(); $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation { diff --git a/tests/Integration/Execution/MutationExecutor/HasManyTest.php b/tests/Integration/Execution/MutationExecutor/HasManyTest.php index c3453722d2..bf564e1de9 100644 --- a/tests/Integration/Execution/MutationExecutor/HasManyTest.php +++ b/tests/Integration/Execution/MutationExecutor/HasManyTest.php @@ -2,7 +2,6 @@ namespace Tests\Integration\Execution\MutationExecutor; -use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\CustomPrimaryKey; @@ -316,38 +315,6 @@ public function testCreateUsingUpsertWithNewHasMany(): void ]); } - public function testNestedUpsertByIDDoesNotModifyUnrelatedHasManyModel(): void - { - $userA = factory(User::class)->create(); - $userB = factory(User::class)->create(); - - $taskA = factory(Task::class)->make(); - $taskA->name = 'from-user-a'; - $taskA->user()->associate($userA); - $taskA->save(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation ($userID: ID!, $taskID: ID!) { - upsertUser(input: { - id: $userID - name: "user-b" - tasks: { - upsert: [{ id: $taskID, name: "hacked" }] - } - }) { - id - } - } - GRAPHQL, [ - 'userID' => $userB->id, - 'taskID' => $taskA->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); - - $taskA->refresh(); - $this->assertSame('from-user-a', $taskA->name); - $this->assertSame($userA->id, $taskA->user_id); - } - /** @return iterable */ public static function existingModelMutations(): iterable { diff --git a/tests/Integration/Execution/MutationExecutor/HasOneTest.php b/tests/Integration/Execution/MutationExecutor/HasOneTest.php index 638ea93e7f..b6856e22c7 100644 --- a/tests/Integration/Execution/MutationExecutor/HasOneTest.php +++ b/tests/Integration/Execution/MutationExecutor/HasOneTest.php @@ -2,7 +2,6 @@ namespace Tests\Integration\Execution\MutationExecutor; -use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\Post; @@ -185,38 +184,6 @@ public function testCreateUsingUpsertWithNewHasOne(): void ]); } - public function testNestedUpsertByIDDoesNotModifyUnrelatedHasOneModel(): void - { - $taskA = factory(Task::class)->create(); - $taskB = factory(Task::class)->create(); - - $postA = factory(Post::class)->make(); - $postA->title = 'from-task-a'; - $postA->task()->associate($taskA); - $postA->save(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation ($taskID: ID!, $postID: ID!) { - upsertTask(input: { - id: $taskID - name: "task-b" - post: { - upsert: { id: $postID, title: "hacked" } - } - }) { - id - } - } - GRAPHQL, [ - 'taskID' => $taskB->id, - 'postID' => $postA->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); - - $postA->refresh(); - $this->assertSame('from-task-a', $postA->title); - $this->assertSame($taskA->id, $postA->task_id); - } - public function testUpsertHasOneWithoutID(): void { $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' diff --git a/tests/Integration/Execution/MutationExecutor/MorphManyTest.php b/tests/Integration/Execution/MutationExecutor/MorphManyTest.php index f71292cc83..497cd79de4 100644 --- a/tests/Integration/Execution/MutationExecutor/MorphManyTest.php +++ b/tests/Integration/Execution/MutationExecutor/MorphManyTest.php @@ -2,7 +2,6 @@ namespace Tests\Integration\Execution\MutationExecutor; -use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\Image; @@ -227,38 +226,6 @@ public function testUpsertMorphManyWithoutId(): void ]); } - public function testNestedUpsertByIDDoesNotModifyUnrelatedMorphManyModel(): void - { - $taskA = factory(Task::class)->create(); - $taskB = factory(Task::class)->create(); - - $imageA = factory(Image::class)->make(); - $imageA->url = 'from-task-a'; - $imageA->imageable()->associate($taskA); - $imageA->save(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation ($taskID: ID!, $imageID: ID!) { - upsertTask(input: { - id: $taskID - name: "task-b" - images: { - upsert: [{ id: $imageID, url: "hacked" }] - } - }) { - id - } - } - GRAPHQL, [ - 'taskID' => $taskB->id, - 'imageID' => $imageA->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); - - $imageA->refresh(); - $this->assertSame('from-task-a', $imageA->url); - $this->assertSame($taskA->id, $imageA->imageable_id); - } - public function testAllowsNullOperations(): void { factory(Task::class)->create(); diff --git a/tests/Integration/Execution/MutationExecutor/MorphOneTest.php b/tests/Integration/Execution/MutationExecutor/MorphOneTest.php index b0cbedfbb6..915e287a39 100644 --- a/tests/Integration/Execution/MutationExecutor/MorphOneTest.php +++ b/tests/Integration/Execution/MutationExecutor/MorphOneTest.php @@ -2,7 +2,6 @@ namespace Tests\Integration\Execution\MutationExecutor; -use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; use Tests\Utils\Models\Image; @@ -178,38 +177,6 @@ public function testUpsertMorphOneWithoutId(): void ]); } - public function testNestedUpsertByIDDoesNotModifyUnrelatedMorphOneModel(): void - { - $taskA = factory(Task::class)->create(); - $taskB = factory(Task::class)->create(); - - $imageA = factory(Image::class)->make(); - $imageA->url = 'from-task-a'; - $imageA->imageable()->associate($taskA); - $imageA->save(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation ($taskID: ID!, $imageID: ID!) { - upsertTask(input: { - id: $taskID - name: "task-b" - image: { - upsert: { id: $imageID, url: "hacked" } - } - }) { - id - } - } - GRAPHQL, [ - 'taskID' => $taskB->id, - 'imageID' => $imageA->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); - - $imageA->refresh(); - $this->assertSame('from-task-a', $imageA->url); - $this->assertSame($taskA->id, $imageA->imageable_id); - } - public function testAllowsNullOperations(): void { factory(Task::class)->create(); @@ -448,5 +415,4 @@ public function testNestedConnectMorphOne(): void ], ]); } - } diff --git a/tests/Integration/Execution/MutationExecutor/MorphToManyTest.php b/tests/Integration/Execution/MutationExecutor/MorphToManyTest.php index 8e8714946a..2114e23f3b 100644 --- a/tests/Integration/Execution/MutationExecutor/MorphToManyTest.php +++ b/tests/Integration/Execution/MutationExecutor/MorphToManyTest.php @@ -2,10 +2,8 @@ namespace Tests\Integration\Execution\MutationExecutor; -use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use Tests\DBTestCase; use Tests\Utils\Models\Tag; -use Tests\Utils\Models\Task; final class MorphToManyTest extends DBTestCase { @@ -224,37 +222,6 @@ public function testUpsertATaskWithExistingTagsByUsingSync(): void ]); } - public function testNestedUpsertByIDDoesNotModifyUnrelatedMorphToManyModel(): void - { - $taskA = factory(Task::class)->create(); - $taskB = factory(Task::class)->create(); - $tagA = factory(Tag::class)->create(); - - $taskA->tags()->attach($tagA); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation ($taskID: ID!, $tagID: ID!) { - upsertTask(input: { - id: $taskID - name: "task-b" - tags: { - upsert: [{ id: $tagID, name: "hacked" }] - } - }) { - id - } - } - GRAPHQL, [ - 'taskID' => $taskB->id, - 'tagID' => $tagA->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); - - $tagA->refresh(); - $this->assertNotSame('hacked', $tagA->name); - $this->assertCount(1, $taskA->tags()->whereKey($tagA->id)->get()); - $this->assertCount(0, $taskB->tags()->whereKey($tagA->id)->get()); - } - public function testCreateANewTagRelationByUsingCreate(): void { $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' diff --git a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php index 3eb3626e6b..14ef234253 100644 --- a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php @@ -4,10 +4,10 @@ use GraphQL\Type\Definition\Type; use Illuminate\Container\Container; -use Nuwave\Lighthouse\Exceptions\DefinitionException; use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use Nuwave\Lighthouse\Schema\TypeRegistry; use Tests\DBTestCase; +use Tests\Utils\Models\Company; use Tests\Utils\Models\Task; use Tests\Utils\Models\User; @@ -210,10 +210,6 @@ interface IUser public function testDirectUpsertByIdentifyingColumn(): void { - $email = 'foo@te.st'; - $originalName = 'bar'; - $updatedName = 'foo'; - $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' type User { id: ID! @@ -227,192 +223,135 @@ public function testDirectUpsertByIdentifyingColumn(): void GRAPHQL; $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation ($email: String!, $name: String!) { + mutation { upsertUser( - email: $email - name: $name + email: "foo@te.st" + name: "bar" ) { name email } } - GRAPHQL, [ - 'email' => $email, - 'name' => $originalName, - ])->assertJson([ + GRAPHQL)->assertJson([ 'data' => [ 'upsertUser' => [ - 'email' => $email, - 'name' => $originalName, + 'email' => 'foo@te.st', + 'name' => 'bar', ], ], ]); $user = User::firstOrFail(); - $this->assertSame($originalName, $user->name); - $this->assertSame($email, $user->email); + $this->assertSame('bar', $user->name); + $this->assertSame('foo@te.st', $user->email); $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation ($email: String!, $name: String!) { + mutation { upsertUser( - email: $email - name: $name + email: "foo@te.st" + name: "foo" ) { name email } } - GRAPHQL, [ - 'email' => $email, - 'name' => $updatedName, - ])->assertJson([ + GRAPHQL)->assertJson([ 'data' => [ 'upsertUser' => [ - 'email' => $email, - 'name' => $updatedName, + 'email' => 'foo@te.st', + 'name' => 'foo', ], ], ]); $user->refresh(); - $this->assertSame($updatedName, $user->name); - $this->assertSame($email, $user->email); - } - - public function testDirectUpsertByIdentifyingColumnsMustNotBeEmpty(): void - { - $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' - type User { - id: ID! - email: String! - name: String! - } - - type Mutation { - upsertUser(name: String!, email: String!): User @upsert(identifyingColumns: []) - } - GRAPHQL; - - $this->expectException(DefinitionException::class); - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - upsertUser( - email: "foo@te.st" - name: "bar" - ) { - id - } - } - GRAPHQL); - } - - public function testNestedUpsertByIdentifyingColumnsMustNotBeEmpty(): void - { - $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' - type Mutation { - updateUser(input: UpdateUserInput! @spread): User @update - } - - type Task { - id: Int - name: String! - } - - type User { - id: Int - tasks: [Task!]! @hasMany - } - - input UpdateUserInput { - id: Int - tasks: [UpdateTaskInput!] @upsert(relation: "tasks", identifyingColumns: []) - } - - input UpdateTaskInput { - id: Int - name: String - } - GRAPHQL; - - $this->expectException(DefinitionException::class); - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - updateUser(input: {id: 1}) { - id - } - } - GRAPHQL); + $this->assertSame('foo', $user->name); + $this->assertSame('foo@te.st', $user->email); } public function testDirectUpsertByIdentifyingColumns(): void { - $user = factory(User::class)->make(); - $user->name = 'bar'; - $user->email = 'foo@te.st'; - $user->password = 'old-password'; - $user->save(); + $company = factory(Company::class)->make(); + $company->id = 1; + $company->save(); - $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + $this->schema + /** @lang GraphQL */ + .= ' type User { id: ID! email: String! name: String! + company_id: ID! } type Mutation { - upsertUser(name: String!, email: String!, password: String!): User @upsert(identifyingColumns: ["name", "email"]) + upsertUser(name: String!, email: String!, company_id:ID!): User @upsert(identifyingColumns: ["name", "company_id"]) } - GRAPHQL; + '; - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + $this->graphQL( + /** @lang GraphQL */ + ' mutation { upsertUser( email: "foo@te.st" name: "bar" - password: "new-password" + company_id: 1 ) { name email + company_id } } - GRAPHQL)->assertJson([ + ', + )->assertJson([ 'data' => [ 'upsertUser' => [ 'email' => 'foo@te.st', 'name' => 'bar', + 'company_id' => 1, ], ], ]); - $this->assertSame(1, User::count()); $user = User::firstOrFail(); - $this->assertSame('new-password', $user->password); - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + $this->assertSame('bar', $user->name); + $this->assertSame('foo@te.st', $user->email); + $this->assertSame(1, $user->company_id); + + $this->graphQL( + /** @lang GraphQL */ + ' mutation { upsertUser( - email: "foo@te.st" + email: "bar@te.st" name: "bar" - password: "newer-password" + company_id: 1 ) { name email + company_id } } - GRAPHQL)->assertJson([ + ', + )->assertJson([ 'data' => [ 'upsertUser' => [ - 'email' => 'foo@te.st', + 'email' => 'bar@te.st', 'name' => 'bar', + 'company_id' => $company->id, ], ], ]); - $this->assertSame(1, User::count()); $user->refresh(); - $this->assertSame('newer-password', $user->password); + + $this->assertSame('bar', $user->name); + $this->assertSame('bar@te.st', $user->email); } public function testDirectUpsertByIdentifyingColumnsRequiresAllConfiguredColumns(): void @@ -536,7 +475,8 @@ public function testNestedUpsertByIdDoesNotModifySiblingParentsRelatedModel(): v } GRAPHQL; - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + $this->graphQL( + /** @lang GraphQL */ <<<'GRAPHQL' mutation ($userID: Int!, $taskID: Int!) { updateUser(input: { id: $userID @@ -545,10 +485,12 @@ public function testNestedUpsertByIdDoesNotModifySiblingParentsRelatedModel(): v id } } - GRAPHQL, [ - 'userID' => $userB->id, - 'taskID' => $taskA->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + GRAPHQL, + [ + 'userID' => $userB->id, + 'taskID' => $taskA->id, + ], + )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); $taskA->refresh(); $this->assertSame($userA->id, $taskA->user_id); @@ -593,7 +535,8 @@ public function testNestedUpsertByIdentifyingColumnDoesNotModifySiblingParentsRe } GRAPHQL; - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + $this->graphQL( + /** @lang GraphQL */ <<<'GRAPHQL' mutation ($userID: Int!) { updateUser(input: { id: $userID @@ -606,9 +549,11 @@ public function testNestedUpsertByIdentifyingColumnDoesNotModifySiblingParentsRe } } } - GRAPHQL, [ - 'userID' => $userB->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + GRAPHQL, + [ + 'userID' => $userB->id, + ], + )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); $taskA->refresh(); $this->assertSame($userA->id, $taskA->user_id); diff --git a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php index 35819d65ad..fb26e5c3bb 100644 --- a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php @@ -4,7 +4,6 @@ use GraphQL\Type\Definition\Type; use Illuminate\Container\Container; -use Nuwave\Lighthouse\Exceptions\DefinitionException; use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use Nuwave\Lighthouse\Schema\TypeRegistry; use Tests\DBTestCase; @@ -321,35 +320,6 @@ public function testDirectUpsertManyByIdentifyingColumnsRequiresAllConfiguredCol GRAPHQL)->assertGraphQLErrorMessage(UpsertModel::MISSING_IDENTIFYING_COLUMNS_FOR_UPSERT); } - public function testDirectUpsertManyByIdentifyingColumnsMustNotBeEmpty(): void - { - $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' - type User { - id: ID! - email: String! - name: String! - } - - input UpsertUserInput { - email: String! - name: String! - } - - type Mutation { - upsertUsers(inputs: [UpsertUserInput!]!): [User!]! @upsertMany(identifyingColumns: []) - } - GRAPHQL; - - $this->expectException(DefinitionException::class); - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - upsertUsers(inputs: [{ email: "foo@te.st", name: "bar" }]) { - id - } - } - GRAPHQL); - } - public function testNestedUpsertManyByIdDoesNotModifySiblingParentsRelatedModel(): void { $userA = factory(User::class)->create(); @@ -385,7 +355,8 @@ public function testNestedUpsertManyByIdDoesNotModifySiblingParentsRelatedModel( } GRAPHQL; - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + $this->graphQL( + /** @lang GraphQL */ <<<'GRAPHQL' mutation ($userID: Int!, $taskID: Int!) { updateUser(input: { id: $userID @@ -394,10 +365,12 @@ public function testNestedUpsertManyByIdDoesNotModifySiblingParentsRelatedModel( id } } - GRAPHQL, [ - 'userID' => $userB->id, - 'taskID' => $taskA->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + GRAPHQL, + [ + 'userID' => $userB->id, + 'taskID' => $taskA->id, + ], + )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); $taskA->refresh(); $this->assertSame($userA->id, $taskA->user_id); @@ -441,7 +414,8 @@ public function testNestedUpsertManyByIdentifyingColumnDoesNotModifySiblingParen } GRAPHQL; - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + $this->graphQL( + /** @lang GraphQL */ <<<'GRAPHQL' mutation ($userID: Int!) { updateUser(input: { id: $userID @@ -454,9 +428,11 @@ public function testNestedUpsertManyByIdentifyingColumnDoesNotModifySiblingParen } } } - GRAPHQL, [ - 'userID' => $userB->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + GRAPHQL, + [ + 'userID' => $userB->id, + ], + )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); $taskA->refresh(); $this->assertSame($userA->id, $taskA->user_id); From 9f2d6a82e19d23e9d1c8dfd2257a83b49fd22497 Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 14:35:52 +0000 Subject: [PATCH 16/31] Apply php-cs-fixer changes --- tests/Integration/Schema/Directives/UpsertDirectiveTest.php | 6 ++++-- .../Schema/Directives/UpsertManyDirectiveTest.php | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php index 14ef234253..473024ba53 100644 --- a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php @@ -476,7 +476,8 @@ public function testNestedUpsertByIdDoesNotModifySiblingParentsRelatedModel(): v GRAPHQL; $this->graphQL( - /** @lang GraphQL */ <<<'GRAPHQL' + /** @lang GraphQL */ + <<<'GRAPHQL' mutation ($userID: Int!, $taskID: Int!) { updateUser(input: { id: $userID @@ -536,7 +537,8 @@ public function testNestedUpsertByIdentifyingColumnDoesNotModifySiblingParentsRe GRAPHQL; $this->graphQL( - /** @lang GraphQL */ <<<'GRAPHQL' + /** @lang GraphQL */ + <<<'GRAPHQL' mutation ($userID: Int!) { updateUser(input: { id: $userID diff --git a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php index fb26e5c3bb..5c3df6ca8b 100644 --- a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php @@ -356,7 +356,8 @@ public function testNestedUpsertManyByIdDoesNotModifySiblingParentsRelatedModel( GRAPHQL; $this->graphQL( - /** @lang GraphQL */ <<<'GRAPHQL' + /** @lang GraphQL */ + <<<'GRAPHQL' mutation ($userID: Int!, $taskID: Int!) { updateUser(input: { id: $userID @@ -415,7 +416,8 @@ public function testNestedUpsertManyByIdentifyingColumnDoesNotModifySiblingParen GRAPHQL; $this->graphQL( - /** @lang GraphQL */ <<<'GRAPHQL' + /** @lang GraphQL */ + <<<'GRAPHQL' mutation ($userID: Int!) { updateUser(input: { id: $userID From 4178afb893da654b64622c702a0249af7e6fd55a Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 16:01:37 +0100 Subject: [PATCH 17/31] fix --- src/Execution/Arguments/UpsertModel.php | 6 ++- .../Schema/Directives/UpsertDirectiveTest.php | 46 +++++++------------ .../Directives/UpsertManyDirectiveTest.php | 24 ++++------ 3 files changed, 30 insertions(+), 46 deletions(-) diff --git a/src/Execution/Arguments/UpsertModel.php b/src/Execution/Arguments/UpsertModel.php index 066475df66..f244eda901 100644 --- a/src/Execution/Arguments/UpsertModel.php +++ b/src/Execution/Arguments/UpsertModel.php @@ -78,7 +78,11 @@ public function __invoke($model, $args): mixed return ($this->previous)($model, $args); } - /** @return array|null */ + /** + * @param array $identifyingColumns + * + * @return array|null + */ protected function identifyingColumnValues(ArgumentSet $args, array $identifyingColumns): ?array { $identifyingValues = array_intersect_key( diff --git a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php index 14ef234253..8ca33331f0 100644 --- a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php @@ -277,9 +277,7 @@ public function testDirectUpsertByIdentifyingColumns(): void $company->id = 1; $company->save(); - $this->schema - /** @lang GraphQL */ - .= ' + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' type User { id: ID! email: String! @@ -288,13 +286,11 @@ public function testDirectUpsertByIdentifyingColumns(): void } type Mutation { - upsertUser(name: String!, email: String!, company_id:ID!): User @upsert(identifyingColumns: ["name", "company_id"]) + upsertUser(name: String!, email: String!, company_id: ID!): User @upsert(identifyingColumns: ["name", "company_id"]) } - '; + GRAPHQL; - $this->graphQL( - /** @lang GraphQL */ - ' + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation { upsertUser( email: "foo@te.st" @@ -306,8 +302,7 @@ public function testDirectUpsertByIdentifyingColumns(): void company_id } } - ', - )->assertJson([ + GRAPHQL)->assertJson([ 'data' => [ 'upsertUser' => [ 'email' => 'foo@te.st', @@ -323,9 +318,7 @@ public function testDirectUpsertByIdentifyingColumns(): void $this->assertSame('foo@te.st', $user->email); $this->assertSame(1, $user->company_id); - $this->graphQL( - /** @lang GraphQL */ - ' + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation { upsertUser( email: "bar@te.st" @@ -337,8 +330,7 @@ public function testDirectUpsertByIdentifyingColumns(): void company_id } } - ', - )->assertJson([ + GRAPHQL)->assertJson([ 'data' => [ 'upsertUser' => [ 'email' => 'bar@te.st', @@ -475,8 +467,7 @@ public function testNestedUpsertByIdDoesNotModifySiblingParentsRelatedModel(): v } GRAPHQL; - $this->graphQL( - /** @lang GraphQL */ <<<'GRAPHQL' + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation ($userID: Int!, $taskID: Int!) { updateUser(input: { id: $userID @@ -485,12 +476,10 @@ public function testNestedUpsertByIdDoesNotModifySiblingParentsRelatedModel(): v id } } - GRAPHQL, - [ - 'userID' => $userB->id, - 'taskID' => $taskA->id, - ], - )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + GRAPHQL, [ + 'userID' => $userB->id, + 'taskID' => $taskA->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); $taskA->refresh(); $this->assertSame($userA->id, $taskA->user_id); @@ -535,8 +524,7 @@ public function testNestedUpsertByIdentifyingColumnDoesNotModifySiblingParentsRe } GRAPHQL; - $this->graphQL( - /** @lang GraphQL */ <<<'GRAPHQL' + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation ($userID: Int!) { updateUser(input: { id: $userID @@ -549,11 +537,9 @@ public function testNestedUpsertByIdentifyingColumnDoesNotModifySiblingParentsRe } } } - GRAPHQL, - [ - 'userID' => $userB->id, - ], - )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + GRAPHQL, [ + 'userID' => $userB->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); $taskA->refresh(); $this->assertSame($userA->id, $taskA->user_id); diff --git a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php index fb26e5c3bb..d6b219e648 100644 --- a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php @@ -355,8 +355,7 @@ public function testNestedUpsertManyByIdDoesNotModifySiblingParentsRelatedModel( } GRAPHQL; - $this->graphQL( - /** @lang GraphQL */ <<<'GRAPHQL' + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation ($userID: Int!, $taskID: Int!) { updateUser(input: { id: $userID @@ -365,12 +364,10 @@ public function testNestedUpsertManyByIdDoesNotModifySiblingParentsRelatedModel( id } } - GRAPHQL, - [ - 'userID' => $userB->id, - 'taskID' => $taskA->id, - ], - )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + GRAPHQL, [ + 'userID' => $userB->id, + 'taskID' => $taskA->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); $taskA->refresh(); $this->assertSame($userA->id, $taskA->user_id); @@ -414,8 +411,7 @@ public function testNestedUpsertManyByIdentifyingColumnDoesNotModifySiblingParen } GRAPHQL; - $this->graphQL( - /** @lang GraphQL */ <<<'GRAPHQL' + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation ($userID: Int!) { updateUser(input: { id: $userID @@ -428,11 +424,9 @@ public function testNestedUpsertManyByIdentifyingColumnDoesNotModifySiblingParen } } } - GRAPHQL, - [ - 'userID' => $userB->id, - ], - )->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); + GRAPHQL, [ + 'userID' => $userB->id, + ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); $taskA->refresh(); $this->assertSame($userA->id, $taskA->user_id); From f3ff39ea2bab9c6af664fe6dd1222cddda28683d Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 16:04:15 +0100 Subject: [PATCH 18/31] fix --- tests/Integration/Schema/Directives/UpsertDirectiveTest.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php index de921fb9f4..8ca33331f0 100644 --- a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php @@ -467,8 +467,7 @@ public function testNestedUpsertByIdDoesNotModifySiblingParentsRelatedModel(): v } GRAPHQL; - $this->graphQL(/** @lang GraphQL */ - <<<'GRAPHQL' + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation ($userID: Int!, $taskID: Int!) { updateUser(input: { id: $userID @@ -525,8 +524,7 @@ public function testNestedUpsertByIdentifyingColumnDoesNotModifySiblingParentsRe } GRAPHQL; - $this->graphQL(/** @lang GraphQL */ - <<<'GRAPHQL' + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation ($userID: Int!) { updateUser(input: { id: $userID From d41c13487de8f5cc4749f9492f842cef31d0886b Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 16:09:20 +0100 Subject: [PATCH 19/31] Validate empty identifyingColumns during schema build --- .../Schema/Directives/UpsertDirectiveTest.php | 47 +++++++++++++++++ .../Directives/UpsertManyDirectiveTest.php | 52 +++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php index 8ca33331f0..b1e954d5b4 100644 --- a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php @@ -4,6 +4,7 @@ use GraphQL\Type\Definition\Type; use Illuminate\Container\Container; +use Nuwave\Lighthouse\Exceptions\DefinitionException; use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use Nuwave\Lighthouse\Schema\TypeRegistry; use Tests\DBTestCase; @@ -346,6 +347,52 @@ public function testDirectUpsertByIdentifyingColumns(): void $this->assertSame('bar@te.st', $user->email); } + public function testDirectUpsertByIdentifyingColumnsMustNotBeEmpty(): void + { + $this->expectException(DefinitionException::class); + $this->buildSchema(/** @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + email: String! + name: String! + } + + type Mutation { + upsertUser(name: String!, email: String!): User @upsert(identifyingColumns: []) + } + GRAPHQL . self::PLACEHOLDER_QUERY); + } + + public function testNestedUpsertByIdentifyingColumnsMustNotBeEmpty(): void + { + $this->expectException(DefinitionException::class); + $this->buildSchema(/** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + updateUser(input: UpdateUserInput! @spread): User @update + } + + type Task { + id: Int + name: String! + } + + type User { + id: Int + tasks: [Task!]! @hasMany + } + + input UpdateUserInput { + id: Int + tasks: [UpdateTaskInput!] @upsert(relation: "tasks", identifyingColumns: []) + } + + input UpdateTaskInput { + id: Int + name: String + } + GRAPHQL . self::PLACEHOLDER_QUERY); + } + public function testDirectUpsertByIdentifyingColumnsRequiresAllConfiguredColumns(): void { $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' diff --git a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php index d6b219e648..01e593730f 100644 --- a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php @@ -4,6 +4,7 @@ use GraphQL\Type\Definition\Type; use Illuminate\Container\Container; +use Nuwave\Lighthouse\Exceptions\DefinitionException; use Nuwave\Lighthouse\Execution\Arguments\UpsertModel; use Nuwave\Lighthouse\Schema\TypeRegistry; use Tests\DBTestCase; @@ -292,6 +293,57 @@ public function testDirectUpsertManyByIdentifyingColumn(): void $this->assertSame('updated', User::where('email', 'foo@te.st')->firstOrFail()->name); } + public function testDirectUpsertManyByIdentifyingColumnsMustNotBeEmpty(): void + { + $this->expectException(DefinitionException::class); + $this->buildSchema(/** @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + email: String! + name: String! + } + + input UpsertUserInput { + email: String! + name: String! + } + + type Mutation { + upsertUsers(inputs: [UpsertUserInput!]!): [User!]! @upsertMany(identifyingColumns: []) + } + GRAPHQL . self::PLACEHOLDER_QUERY); + } + + public function testNestedUpsertManyByIdentifyingColumnsMustNotBeEmpty(): void + { + $this->expectException(DefinitionException::class); + $this->buildSchema(/** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + updateUser(input: UpdateUserInput! @spread): User @update + } + + type Task { + id: Int + name: String! + } + + type User { + id: Int + tasks: [Task!]! @hasMany + } + + input UpdateUserInput { + id: Int + tasks: [UpdateTaskInput!] @upsertMany(relation: "tasks", identifyingColumns: []) + } + + input UpdateTaskInput { + id: Int + name: String + } + GRAPHQL . self::PLACEHOLDER_QUERY); + } + public function testDirectUpsertManyByIdentifyingColumnsRequiresAllConfiguredColumns(): void { $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' From d4bd08639f0f3b0b7f89f12005a0267e61513607 Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 16:12:39 +0100 Subject: [PATCH 20/31] Remove unrelated-model upsert guard and tests --- src/Execution/Arguments/UpsertModel.php | 21 +--- .../Schema/Directives/UpsertDirectiveTest.php | 114 ------------------ .../Directives/UpsertManyDirectiveTest.php | 113 ----------------- 3 files changed, 2 insertions(+), 246 deletions(-) diff --git a/src/Execution/Arguments/UpsertModel.php b/src/Execution/Arguments/UpsertModel.php index f244eda901..aff116c9cf 100644 --- a/src/Execution/Arguments/UpsertModel.php +++ b/src/Execution/Arguments/UpsertModel.php @@ -12,8 +12,6 @@ class UpsertModel implements ArgResolver { public const MISSING_IDENTIFYING_COLUMNS_FOR_UPSERT = 'All configured identifying columns must be present and non-null for upsert.'; - public const CANNOT_UPSERT_UNRELATED_MODEL = 'Cannot upsert a model that is not related to the given parent.'; - /** @var callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver */ protected $previous; @@ -44,13 +42,6 @@ public function __invoke($model, $args): mixed ?? throw new Error(self::MISSING_IDENTIFYING_COLUMNS_FOR_UPSERT); $existingModel = $this->queryBuilder($model)->firstWhere($identifyingColumns); - if ( - $existingModel === null - && $this->parentRelation !== null - && $model->newQuery()->where($identifyingColumns)->exists() - ) { - throw new Error(self::CANNOT_UPSERT_UNRELATED_MODEL); - } if ($existingModel !== null) { $model = $existingModel; @@ -61,14 +52,6 @@ public function __invoke($model, $args): mixed $id = $this->retrieveID($model, $args); if ($id) { $existingModel = $this->queryBuilder($model)->find($id); - if ( - $existingModel === null - && $this->parentRelation !== null - && $model->newQuery()->find($id) !== null - ) { - throw new Error(self::CANNOT_UPSERT_UNRELATED_MODEL); - } - if ($existingModel !== null) { $model = $existingModel; } @@ -94,8 +77,8 @@ protected function identifyingColumnValues(ArgumentSet $args, array $identifying return null; } - foreach ($identifyingValues as $identifyingColumn) { - if ($identifyingColumn === null) { + foreach ($identifyingValues as $identifyingValue) { + if ($identifyingValue === null) { return null; } } diff --git a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php index b1e954d5b4..cdefd93981 100644 --- a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php @@ -479,120 +479,6 @@ public function testUpsertByIdentifyingColumnWithInputSpread(): void $this->assertSame('baz', User::firstOrFail()->name); } - public function testNestedUpsertByIdDoesNotModifySiblingParentsRelatedModel(): void - { - $userA = factory(User::class)->create(); - $userB = factory(User::class)->create(); - $taskA = factory(Task::class)->make(); - $taskA->name = 'from-user-a'; - $taskA->user()->associate($userA); - $taskA->save(); - - $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' - type Mutation { - updateUser(input: UpdateUserInput! @spread): User @update - } - - type Task { - id: Int - name: String! - } - - type User { - id: Int - tasks: [Task!]! @hasMany - } - - input UpdateUserInput { - id: Int - tasks: [UpdateTaskInput!] @upsert(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' => $userB->id, - 'taskID' => $taskA->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); - - $taskA->refresh(); - $this->assertSame($userA->id, $taskA->user_id); - $this->assertSame('from-user-a', $taskA->name); - } - - public function testNestedUpsertByIdentifyingColumnDoesNotModifySiblingParentsRelatedModel(): void - { - $userA = factory(User::class)->create(); - $userB = factory(User::class)->create(); - $taskA = factory(Task::class)->make(); - $taskA->name = 'same-name'; - $taskA->difficulty = 1; - $taskA->user()->associate($userA); - $taskA->save(); - - $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' - type Mutation { - updateUser(input: UpdateUserInput! @spread): User @update - } - - type Task { - id: Int - name: String! - difficulty: Int - } - - type User { - id: Int - tasks: [Task!]! @hasMany - } - - input UpdateUserInput { - id: Int - tasks: [UpdateTaskInput!] @upsert(relation: "tasks", identifyingColumns: ["name"]) - } - - input UpdateTaskInput { - id: Int - name: String! - difficulty: Int - } - GRAPHQL; - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation ($userID: Int!) { - updateUser(input: { - id: $userID - tasks: [{ name: "same-name", difficulty: 2 }] - }) { - id - tasks { - name - difficulty - } - } - } - GRAPHQL, [ - 'userID' => $userB->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); - - $taskA->refresh(); - $this->assertSame($userA->id, $taskA->user_id); - $this->assertSame(1, $taskA->difficulty); - } - public static function resolveType(): Type { $typeRegistry = Container::getInstance()->make(TypeRegistry::class); diff --git a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php index 01e593730f..17a79167d8 100644 --- a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php @@ -372,119 +372,6 @@ public function testDirectUpsertManyByIdentifyingColumnsRequiresAllConfiguredCol GRAPHQL)->assertGraphQLErrorMessage(UpsertModel::MISSING_IDENTIFYING_COLUMNS_FOR_UPSERT); } - public function testNestedUpsertManyByIdDoesNotModifySiblingParentsRelatedModel(): void - { - $userA = factory(User::class)->create(); - $userB = factory(User::class)->create(); - $taskA = factory(Task::class)->make(); - $taskA->name = 'from-user-a'; - $taskA->user()->associate($userA); - $taskA->save(); - - $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' - type Mutation { - updateUser(input: UpdateUserInput! @spread): User @update - } - - type Task { - id: Int - name: String! - } - - type User { - id: Int - tasks: [Task!]! @hasMany - } - - 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' => $userB->id, - 'taskID' => $taskA->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); - - $taskA->refresh(); - $this->assertSame($userA->id, $taskA->user_id); - $this->assertSame('from-user-a', $taskA->name); - } - - public function testNestedUpsertManyByIdentifyingColumnDoesNotModifySiblingParentsRelatedModel(): void - { - $userA = factory(User::class)->create(); - $userB = factory(User::class)->create(); - $taskA = factory(Task::class)->make(); - $taskA->name = 'same-name'; - $taskA->difficulty = 1; - $taskA->user()->associate($userA); - $taskA->save(); - - $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' - type Mutation { - updateUser(input: UpdateUserInput! @spread): User @update - } - - type Task { - id: Int - name: String! - difficulty: Int - } - - type User { - id: Int - tasks: [Task!]! @hasMany - } - - input UpdateUserInput { - id: Int - tasks: [UpdateTaskInput!] @upsertMany(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: "same-name", difficulty: 2 }] - }) { - id - tasks { - name - difficulty - } - } - } - GRAPHQL, [ - 'userID' => $userB->id, - ])->assertGraphQLErrorMessage(UpsertModel::CANNOT_UPSERT_UNRELATED_MODEL); - - $taskA->refresh(); - $this->assertSame($userA->id, $taskA->user_id); - $this->assertSame(1, $taskA->difficulty); - } - public static function resolveType(): Type { $typeRegistry = Container::getInstance()->make(TypeRegistry::class); From 820dae797321070615f71d0558095eb6501df987 Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 16:15:13 +0100 Subject: [PATCH 21/31] Simplify empty identifyingColumns checks in upsert directives --- src/Schema/Directives/UpsertDirective.php | 6 ++---- src/Schema/Directives/UpsertManyDirective.php | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/Schema/Directives/UpsertDirective.php b/src/Schema/Directives/UpsertDirective.php index 426815474c..02faf1d81e 100644 --- a/src/Schema/Directives/UpsertDirective.php +++ b/src/Schema/Directives/UpsertDirective.php @@ -85,10 +85,8 @@ protected function ensureNonEmptyIdentifyingColumns(string $location): void { $identifyingColumns = $this->directiveArgValue('identifyingColumns'); - if (! is_array($identifyingColumns) || $identifyingColumns !== []) { - return; + if ($identifyingColumns === []) { + throw new DefinitionException("Must specify non-empty list of columns in `identifyingColumns` argument of `@{$this->name()}` directive on `{$location}`."); } - - throw new DefinitionException("Must specify non-empty list of columns in `identifyingColumns` argument of `@{$this->name()}` directive on `{$location}`."); } } diff --git a/src/Schema/Directives/UpsertManyDirective.php b/src/Schema/Directives/UpsertManyDirective.php index d41f5f41aa..77730e6aa1 100644 --- a/src/Schema/Directives/UpsertManyDirective.php +++ b/src/Schema/Directives/UpsertManyDirective.php @@ -85,10 +85,8 @@ protected function ensureNonEmptyIdentifyingColumns(string $location): void { $identifyingColumns = $this->directiveArgValue('identifyingColumns'); - if (! is_array($identifyingColumns) || $identifyingColumns !== []) { - return; + if ($identifyingColumns === []) { + throw new DefinitionException("Must specify non-empty list of columns in `identifyingColumns` argument of `@{$this->name()}` directive on `{$location}`."); } - - throw new DefinitionException("Must specify non-empty list of columns in `identifyingColumns` argument of `@{$this->name()}` directive on `{$location}`."); } } From db59215bc266a23d31e6c22a17f11f2bfbf18f47 Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 16:17:46 +0100 Subject: [PATCH 22/31] @var --- src/Execution/Arguments/UpsertModel.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Execution/Arguments/UpsertModel.php b/src/Execution/Arguments/UpsertModel.php index aff116c9cf..b81ef2ffc2 100644 --- a/src/Execution/Arguments/UpsertModel.php +++ b/src/Execution/Arguments/UpsertModel.php @@ -15,12 +15,10 @@ class UpsertModel implements ArgResolver /** @var callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver */ protected $previous; - /** - * @param callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver $previous - * @param array|null $identifyingColumns - */ + /** @param callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver $previous */ public function __construct( callable $previous, + /** @var array|null */ protected ?array $identifyingColumns = null, /** @var \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null $parentRelation */ protected ?Relation $parentRelation = null, From 61c8318b55b9e1e32769a5198c5b7a0d8ab23189 Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 16:19:16 +0100 Subject: [PATCH 23/31] Clarify why Eloquent native upsert is not used --- src/Execution/Arguments/UpsertModel.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Execution/Arguments/UpsertModel.php b/src/Execution/Arguments/UpsertModel.php index b81ef2ffc2..7a37814ed1 100644 --- a/src/Execution/Arguments/UpsertModel.php +++ b/src/Execution/Arguments/UpsertModel.php @@ -32,7 +32,7 @@ public function __construct( */ public function __invoke($model, $args): mixed { - // TODO consider Laravel native ->upsert(), available from 8.10 + // Do not use Laravel native upsert() here, as it bypasses model hydration and model events. $existingModel = null; if ($this->identifyingColumns) { From 233998eace7739d719518897564f818ba9077e76 Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 16:20:09 +0100 Subject: [PATCH 24/31] avoid double save --- tests/Integration/Schema/Directives/UpsertDirectiveTest.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php index cdefd93981..d7e0a1a72b 100644 --- a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php @@ -21,10 +21,9 @@ public function testNestedArgResolver(): void $user->save(); $task = factory(Task::class)->make(); - $task->id = 1; - $task->user()->associate($user); - $task->save(); $this->assertInstanceOf(Task::class, $task); + $task->user()->associate($user); + $task->id = 1; $task->name = 'old'; $task->save(); From 1cb73aae23a5f3bb2419beddefaa20a60b6555e0 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Wed, 11 Feb 2026 16:21:10 +0100 Subject: [PATCH 25/31] Update CHANGELOG to remove 'Changed' section Removed the 'Changed' section from the CHANGELOG. --- CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 362a05f041..36bad78878 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,6 @@ You can find and compare releases at the [GitHub release page](https://github.co - Specify identifying columns on nested mutation upserts with `@upsert` and `@upsertMany` https://github.com/nuwave/lighthouse/pull/2426 -### Changed - -- Scope nested `@upsert` and `@upsertMany` lookups to their parent relation https://github.com/nuwave/lighthouse/pull/2426 - ## v6.64.3 ### Fixed From 432622d81a8ff9838dc22ac72cb532bffc6cda30 Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 18:26:04 +0100 Subject: [PATCH 26/31] review --- docs/master/api-reference/directives.md | 3 + docs/master/eloquent/getting-started.md | 2 + .../Schema/Directives/UpsertDirectiveTest.php | 72 +++++++++++++++++ .../Directives/UpsertManyDirectiveTest.php | 80 +++++++++++++++++++ 4 files changed, 157 insertions(+) diff --git a/docs/master/api-reference/directives.md b/docs/master/api-reference/directives.md index 273c0de5be..7f70539411 100644 --- a/docs/master/api-reference/directives.md +++ b/docs/master/api-reference/directives.md @@ -4050,6 +4050,7 @@ type Mutation { ``` When you pass `identifyingColumns`, Lighthouse will first try to match an existing model through those columns and only then fall back to `id`. +All configured identifying columns must be present with non-null values, otherwise the upsert fails with a GraphQL error. ```graphql type Mutation { @@ -4115,6 +4116,8 @@ input UpsertUserInput { } ``` +For `@upsertMany`, all configured identifying columns must be present with non-null values for every input item, otherwise the upsert fails with a GraphQL error. + ## @validator ```graphql diff --git a/docs/master/eloquent/getting-started.md b/docs/master/eloquent/getting-started.md index 6bc8f01cdb..b0942e9d68 100644 --- a/docs/master/eloquent/getting-started.md +++ b/docs/master/eloquent/getting-started.md @@ -269,6 +269,8 @@ type Mutation { } ``` +When using `identifyingColumns`, all configured identifying columns must be provided with non-null values, otherwise the upsert fails with a GraphQL error. + Since upsert can create or update your data, your input should mark the minimum required fields as non-nullable. ```graphql diff --git a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php index d7e0a1a72b..49324c7c41 100644 --- a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php @@ -392,6 +392,78 @@ public function testNestedUpsertByIdentifyingColumnsMustNotBeEmpty(): void GRAPHQL . self::PLACEHOLDER_QUERY); } + public function testNestedUpsertByIdentifyingColumn(): void + { + $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 { + id: Int + 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 */ <<id} + tasks: [ + { + name: "existing-task" + difficulty: 2 + } + ] + }) { + tasks { + name + difficulty + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'updateUser' => [ + 'tasks' => [ + [ + 'name' => 'existing-task', + 'difficulty' => 2, + ], + ], + ], + ], + ]); + + $task->refresh(); + + $this->assertSame(2, $task->difficulty); + $this->assertSame(1, Task::count()); + } + public function testDirectUpsertByIdentifyingColumnsRequiresAllConfiguredColumns(): void { $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' diff --git a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php index 17a79167d8..dfa82c1869 100644 --- a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php @@ -344,6 +344,86 @@ public function testNestedUpsertManyByIdentifyingColumnsMustNotBeEmpty(): void GRAPHQL . self::PLACEHOLDER_QUERY); } + public function testNestedUpsertManyByIdentifyingColumn(): void + { + $user = factory(User::class)->create(); + $existingTask = factory(Task::class)->make(); + $existingTask->name = 'existing-task-many'; + $existingTask->difficulty = 1; + $existingTask->user()->associate($user); + $existingTask->save(); + + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + updateUser(input: UpdateUserInput! @spread): User @update + } + + type Task { + id: Int + name: String! + difficulty: Int + } + + type User { + id: Int + tasks: [Task!]! @hasMany + } + + input UpdateUserInput { + id: Int + tasks: [UpdateTaskInput!] @upsertMany(relation: "tasks", identifyingColumns: ["name"]) + } + + input UpdateTaskInput { + name: String! + difficulty: Int + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<id} + tasks: [ + { + name: "existing-task-many" + difficulty: 2 + } + { + name: "new-task-many" + difficulty: 3 + } + ] + }) { + tasks { + name + difficulty + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'updateUser' => [ + 'tasks' => [ + [ + 'name' => 'existing-task-many', + 'difficulty' => 2, + ], + [ + 'name' => 'new-task-many', + 'difficulty' => 3, + ], + ], + ], + ], + ]); + + $existingTask->refresh(); + + $this->assertSame(2, $existingTask->difficulty); + $this->assertSame(2, Task::count()); + } + public function testDirectUpsertManyByIdentifyingColumnsRequiresAllConfiguredColumns(): void { $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' From 431f6ef770c2aa1bebcfe47c64f33e6c5fbe5bda Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 17:27:23 +0000 Subject: [PATCH 27/31] Apply php-cs-fixer changes --- benchmarks/ASTUnserializationBench.php | 4 +++- benchmarks/BenchmarkTestCase.php | 4 +++- benchmarks/QueryBench.php | 4 +++- src/Async/AsyncRoot.php | 4 +++- src/Auth/CanDirective.php | 4 +++- src/Auth/GuardDirective.php | 4 +++- src/Bind/BindDefinition.php | 4 +++- src/Console/ClearCacheCommand.php | 4 +++- src/Execution/AlwaysReportingErrorHandler.php | 4 +++- src/Execution/AuthenticationErrorHandler.php | 4 +++- src/Execution/AuthorizationErrorHandler.php | 4 +++- src/Execution/ExtensionsResponse.php | 4 +++- src/Execution/ReportingErrorHandler.php | 4 +++- src/Execution/ValidationErrorHandler.php | 4 +++- src/Pagination/ZeroPerPageLengthAwarePaginator.php | 4 +++- src/Pagination/ZeroPerPagePaginator.php | 4 +++- src/Schema/Factories/ArgumentFactory.php | 4 +++- src/Support/Contracts/ComplexityResolverDirective.php | 4 +++- src/Support/Contracts/FieldResolver.php | 4 +++- src/Support/Contracts/ProvidesCacheableValidationRules.php | 4 +++- src/Testing/TestResponseMixin.php | 4 +++- src/Testing/TestsSubscriptions.php | 4 +++- src/Tracing/ApolloTracing/ApolloTracing.php | 4 +++- src/Tracing/FederatedTracing/FederatedTracing.php | 4 +++- tests/Integration/Auth/CanDirectiveDBTest.php | 4 +++- tests/Unit/Auth/CanDirectiveTest.php | 4 +++- tests/Utils/Bind/SpyCallableClassBinding.php | 4 +++- tests/Utils/MockableFoo.php | 4 +++- tests/Utils/Models/User/UserBuilder.php | 4 +++- tests/Utils/Queries/MissingInvoke.php | 4 +++- 30 files changed, 90 insertions(+), 30 deletions(-) diff --git a/benchmarks/ASTUnserializationBench.php b/benchmarks/ASTUnserializationBench.php index 1d52d27ffb..a2fbb11d93 100644 --- a/benchmarks/ASTUnserializationBench.php +++ b/benchmarks/ASTUnserializationBench.php @@ -5,7 +5,9 @@ use GraphQL\Language\Parser; use Nuwave\Lighthouse\Schema\AST\DocumentAST; -/** @BeforeMethods({"prepareSchema"}) */ +/** + * @BeforeMethods({"prepareSchema"}) + */ final class ASTUnserializationBench { public const SCHEMA = /** @lang GraphQL */ <<<'GRAPHQL' diff --git a/benchmarks/BenchmarkTestCase.php b/benchmarks/BenchmarkTestCase.php index 327ac37a72..144877cb5f 100644 --- a/benchmarks/BenchmarkTestCase.php +++ b/benchmarks/BenchmarkTestCase.php @@ -6,7 +6,9 @@ use Illuminate\Testing\TestResponse; use Tests\TestCase; -/** Allows reusing test setup and helpers for benchmarks. */ +/** + * Allows reusing test setup and helpers for benchmarks. + */ final class BenchmarkTestCase extends TestCase { public function setUp(): void diff --git a/benchmarks/QueryBench.php b/benchmarks/QueryBench.php index 8843fedb7c..8dfc43f39d 100644 --- a/benchmarks/QueryBench.php +++ b/benchmarks/QueryBench.php @@ -6,7 +6,9 @@ use Illuminate\Contracts\Foundation\Application; use Illuminate\Testing\TestResponse; -/** @BeforeMethods({"setUp"}) */ +/** + * @BeforeMethods({"setUp"}) + */ abstract class QueryBench { protected BenchmarkTestCase $testCase; diff --git a/src/Async/AsyncRoot.php b/src/Async/AsyncRoot.php index d6e446e04e..e4b50189ea 100644 --- a/src/Async/AsyncRoot.php +++ b/src/Async/AsyncRoot.php @@ -2,7 +2,9 @@ namespace Nuwave\Lighthouse\Async; -/** Used as a marker to signify we are running an async mutation. */ +/** + * Used as a marker to signify we are running an async mutation. + */ class AsyncRoot { public static function instance(): static diff --git a/src/Auth/CanDirective.php b/src/Auth/CanDirective.php index 875dcfc8d7..21eb99249d 100644 --- a/src/Auth/CanDirective.php +++ b/src/Auth/CanDirective.php @@ -29,7 +29,9 @@ use Nuwave\Lighthouse\Support\Contracts\GraphQLContext; use Nuwave\Lighthouse\Support\Utils; -/** @deprecated TODO remove with v7 */ +/** + * @deprecated TODO remove with v7 + */ class CanDirective extends BaseDirective implements FieldMiddleware, FieldManipulator { public function __construct( diff --git a/src/Auth/GuardDirective.php b/src/Auth/GuardDirective.php index 43f4f570a2..e2b1a1ed10 100644 --- a/src/Auth/GuardDirective.php +++ b/src/Auth/GuardDirective.php @@ -17,7 +17,9 @@ use Nuwave\Lighthouse\Support\Contracts\TypeExtensionManipulator; use Nuwave\Lighthouse\Support\Contracts\TypeManipulator; -/** @see \Illuminate\Auth\Middleware\Authenticate */ +/** + * @see \Illuminate\Auth\Middleware\Authenticate + */ class GuardDirective extends BaseDirective implements FieldMiddleware, TypeManipulator, TypeExtensionManipulator { public function __construct( diff --git a/src/Bind/BindDefinition.php b/src/Bind/BindDefinition.php index ff6742a04a..9e5df2251e 100644 --- a/src/Bind/BindDefinition.php +++ b/src/Bind/BindDefinition.php @@ -8,7 +8,9 @@ use Illuminate\Database\Eloquent\Model; use Nuwave\Lighthouse\Exceptions\DefinitionException; -/** @template-covariant TClass of object */ +/** + * @template-covariant TClass of object + */ class BindDefinition { public function __construct( diff --git a/src/Console/ClearCacheCommand.php b/src/Console/ClearCacheCommand.php index ab8e2865c8..8c69c69f25 100644 --- a/src/Console/ClearCacheCommand.php +++ b/src/Console/ClearCacheCommand.php @@ -2,7 +2,9 @@ namespace Nuwave\Lighthouse\Console; -/** @deprecated in favor of lighthouse:clear-schema-cache */ +/** + * @deprecated in favor of lighthouse:clear-schema-cache + */ class ClearCacheCommand extends ClearSchemaCacheCommand { protected $name = 'lighthouse:clear-cache'; diff --git a/src/Execution/AlwaysReportingErrorHandler.php b/src/Execution/AlwaysReportingErrorHandler.php index 51cb46fe15..724fea60ce 100644 --- a/src/Execution/AlwaysReportingErrorHandler.php +++ b/src/Execution/AlwaysReportingErrorHandler.php @@ -5,7 +5,9 @@ use GraphQL\Error\Error; use Illuminate\Contracts\Debug\ExceptionHandler; -/** Report all errors through the default Laravel exception handler. */ +/** + * Report all errors through the default Laravel exception handler. + */ class AlwaysReportingErrorHandler implements ErrorHandler { public function __construct( diff --git a/src/Execution/AuthenticationErrorHandler.php b/src/Execution/AuthenticationErrorHandler.php index f140937134..775a00976c 100644 --- a/src/Execution/AuthenticationErrorHandler.php +++ b/src/Execution/AuthenticationErrorHandler.php @@ -6,7 +6,9 @@ use Illuminate\Auth\AuthenticationException as LaravelAuthenticationException; use Nuwave\Lighthouse\Exceptions\AuthenticationException; -/** Wrap native Laravel authentication exceptions, adding structured data to extensions. */ +/** + * Wrap native Laravel authentication exceptions, adding structured data to extensions. + */ class AuthenticationErrorHandler implements ErrorHandler { public function __invoke(?Error $error, \Closure $next): ?array diff --git a/src/Execution/AuthorizationErrorHandler.php b/src/Execution/AuthorizationErrorHandler.php index 363524f333..83bb0b94ab 100644 --- a/src/Execution/AuthorizationErrorHandler.php +++ b/src/Execution/AuthorizationErrorHandler.php @@ -6,7 +6,9 @@ use Illuminate\Auth\Access\AuthorizationException as LaravelAuthorizationException; use Nuwave\Lighthouse\Exceptions\AuthorizationException; -/** Wrap native Laravel authorization exceptions, adding structured data to extensions. */ +/** + * Wrap native Laravel authorization exceptions, adding structured data to extensions. + */ class AuthorizationErrorHandler implements ErrorHandler { public function __invoke(?Error $error, \Closure $next): ?array diff --git a/src/Execution/ExtensionsResponse.php b/src/Execution/ExtensionsResponse.php index 50d60f0798..5bb4f4f57b 100644 --- a/src/Execution/ExtensionsResponse.php +++ b/src/Execution/ExtensionsResponse.php @@ -2,7 +2,9 @@ namespace Nuwave\Lighthouse\Execution; -/** May be returned from listeners of @see \Nuwave\Lighthouse\Events\BuildExtensionsResponse. */ +/** + * May be returned from listeners of @see \Nuwave\Lighthouse\Events\BuildExtensionsResponse. + */ class ExtensionsResponse { public function __construct( diff --git a/src/Execution/ReportingErrorHandler.php b/src/Execution/ReportingErrorHandler.php index d0523eb859..185e124bea 100644 --- a/src/Execution/ReportingErrorHandler.php +++ b/src/Execution/ReportingErrorHandler.php @@ -5,7 +5,9 @@ use GraphQL\Error\Error; use Illuminate\Contracts\Debug\ExceptionHandler; -/** Report non-client-safe errors through the default Laravel exception handler. */ +/** + * Report non-client-safe errors through the default Laravel exception handler. + */ class ReportingErrorHandler implements ErrorHandler { public function __construct( diff --git a/src/Execution/ValidationErrorHandler.php b/src/Execution/ValidationErrorHandler.php index f246812510..44a9e91288 100644 --- a/src/Execution/ValidationErrorHandler.php +++ b/src/Execution/ValidationErrorHandler.php @@ -6,7 +6,9 @@ use Illuminate\Validation\ValidationException as LaravelValidationException; use Nuwave\Lighthouse\Exceptions\ValidationException; -/** Wrap native Laravel validation exceptions, adding structured data to extensions. */ +/** + * Wrap native Laravel validation exceptions, adding structured data to extensions. + */ class ValidationErrorHandler implements ErrorHandler { public function __invoke(?Error $error, \Closure $next): ?array diff --git a/src/Pagination/ZeroPerPageLengthAwarePaginator.php b/src/Pagination/ZeroPerPageLengthAwarePaginator.php index 21f701aa29..6ce0726551 100644 --- a/src/Pagination/ZeroPerPageLengthAwarePaginator.php +++ b/src/Pagination/ZeroPerPageLengthAwarePaginator.php @@ -5,7 +5,9 @@ use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; -/** @extends \Illuminate\Pagination\LengthAwarePaginator */ +/** + * @extends \Illuminate\Pagination\LengthAwarePaginator + */ class ZeroPerPageLengthAwarePaginator extends LengthAwarePaginator { public function __construct(int $total, int $page) diff --git a/src/Pagination/ZeroPerPagePaginator.php b/src/Pagination/ZeroPerPagePaginator.php index 40e0361b44..a819800f54 100644 --- a/src/Pagination/ZeroPerPagePaginator.php +++ b/src/Pagination/ZeroPerPagePaginator.php @@ -5,7 +5,9 @@ use Illuminate\Pagination\Paginator; use Illuminate\Support\Collection; -/** @extends \Illuminate\Pagination\Paginator */ +/** + * @extends \Illuminate\Pagination\Paginator + */ class ZeroPerPagePaginator extends Paginator { public function __construct(int $page) diff --git a/src/Schema/Factories/ArgumentFactory.php b/src/Schema/Factories/ArgumentFactory.php index 5cbfa08e89..d3bab6e1d8 100644 --- a/src/Schema/Factories/ArgumentFactory.php +++ b/src/Schema/Factories/ArgumentFactory.php @@ -8,7 +8,9 @@ use Nuwave\Lighthouse\Schema\AST\ASTHelper; use Nuwave\Lighthouse\Schema\AST\ExecutableTypeNodeConverter; -/** @phpstan-import-type ArgumentConfig from \GraphQL\Type\Definition\Argument */ +/** + * @phpstan-import-type ArgumentConfig from \GraphQL\Type\Definition\Argument + */ class ArgumentFactory { /** diff --git a/src/Support/Contracts/ComplexityResolverDirective.php b/src/Support/Contracts/ComplexityResolverDirective.php index 27d7dbfb2b..ae1cae5105 100644 --- a/src/Support/Contracts/ComplexityResolverDirective.php +++ b/src/Support/Contracts/ComplexityResolverDirective.php @@ -4,7 +4,9 @@ use Nuwave\Lighthouse\Schema\Values\FieldValue; -/** @phpstan-import-type ComplexityFn from \GraphQL\Type\Definition\FieldDefinition */ +/** + * @phpstan-import-type ComplexityFn from \GraphQL\Type\Definition\FieldDefinition + */ interface ComplexityResolverDirective extends Directive { /** diff --git a/src/Support/Contracts/FieldResolver.php b/src/Support/Contracts/FieldResolver.php index 0c5951ad6c..fa1ad689e0 100644 --- a/src/Support/Contracts/FieldResolver.php +++ b/src/Support/Contracts/FieldResolver.php @@ -4,7 +4,9 @@ use Nuwave\Lighthouse\Schema\Values\FieldValue; -/** @phpstan-import-type Resolver from \Nuwave\Lighthouse\Schema\Values\FieldValue */ +/** + * @phpstan-import-type Resolver from \Nuwave\Lighthouse\Schema\Values\FieldValue + */ interface FieldResolver extends Directive { /** diff --git a/src/Support/Contracts/ProvidesCacheableValidationRules.php b/src/Support/Contracts/ProvidesCacheableValidationRules.php index d0224e631b..e8e8a8c042 100644 --- a/src/Support/Contracts/ProvidesCacheableValidationRules.php +++ b/src/Support/Contracts/ProvidesCacheableValidationRules.php @@ -2,7 +2,9 @@ namespace Nuwave\Lighthouse\Support\Contracts; -/** Allows splitting validation into a cacheable first step and a non-cacheable second step. */ +/** + * Allows splitting validation into a cacheable first step and a non-cacheable second step. + */ interface ProvidesCacheableValidationRules extends ProvidesValidationRules { /** diff --git a/src/Testing/TestResponseMixin.php b/src/Testing/TestResponseMixin.php index b830631245..4db12aca49 100644 --- a/src/Testing/TestResponseMixin.php +++ b/src/Testing/TestResponseMixin.php @@ -14,7 +14,9 @@ use PHPUnit\Framework\Assert; use PHPUnit\Framework\TestCase; -/** @mixin \Illuminate\Testing\TestResponse<*> */ +/** + * @mixin \Illuminate\Testing\TestResponse<*> + */ class TestResponseMixin { public function assertGraphQLValidationError(): \Closure diff --git a/src/Testing/TestsSubscriptions.php b/src/Testing/TestsSubscriptions.php index 0f2d196004..42b9b04331 100644 --- a/src/Testing/TestsSubscriptions.php +++ b/src/Testing/TestsSubscriptions.php @@ -8,7 +8,9 @@ use Nuwave\Lighthouse\Subscriptions\Broadcasters\LogBroadcaster; use Nuwave\Lighthouse\Subscriptions\Contracts\Broadcaster; -/** Sets up the environment for testing subscriptions. */ +/** + * Sets up the environment for testing subscriptions. + */ trait TestsSubscriptions { protected function setUpTestsSubscriptions(): void diff --git a/src/Tracing/ApolloTracing/ApolloTracing.php b/src/Tracing/ApolloTracing/ApolloTracing.php index 8b80c46644..8e519ceef6 100644 --- a/src/Tracing/ApolloTracing/ApolloTracing.php +++ b/src/Tracing/ApolloTracing/ApolloTracing.php @@ -11,7 +11,9 @@ use Nuwave\Lighthouse\Tracing\Tracing; use Nuwave\Lighthouse\Tracing\TracingUtilities; -/** See https://github.com/apollographql/apollo-tracing#response-format. */ +/** + * See https://github.com/apollographql/apollo-tracing#response-format. + */ class ApolloTracing implements Tracing { use TracingUtilities; diff --git a/src/Tracing/FederatedTracing/FederatedTracing.php b/src/Tracing/FederatedTracing/FederatedTracing.php index 25321fdc74..83623bfe7b 100644 --- a/src/Tracing/FederatedTracing/FederatedTracing.php +++ b/src/Tracing/FederatedTracing/FederatedTracing.php @@ -21,7 +21,9 @@ use Nuwave\Lighthouse\Tracing\Tracing; use Nuwave\Lighthouse\Tracing\TracingUtilities; -/** See https://www.apollographql.com/docs/federation/metrics. */ +/** + * See https://www.apollographql.com/docs/federation/metrics. + */ class FederatedTracing implements Tracing { use TracingUtilities; diff --git a/tests/Integration/Auth/CanDirectiveDBTest.php b/tests/Integration/Auth/CanDirectiveDBTest.php index 8a1af82520..faec9fe2a1 100644 --- a/tests/Integration/Auth/CanDirectiveDBTest.php +++ b/tests/Integration/Auth/CanDirectiveDBTest.php @@ -13,7 +13,9 @@ use Tests\Utils\Models\User; use Tests\Utils\Policies\UserPolicy; -/** TODO remove with v7 */ +/** + * TODO remove with v7. + */ final class CanDirectiveDBTest extends DBTestCase { public function testQueriesForSpecificModel(): void diff --git a/tests/Unit/Auth/CanDirectiveTest.php b/tests/Unit/Auth/CanDirectiveTest.php index 47e665299e..bd533adced 100644 --- a/tests/Unit/Auth/CanDirectiveTest.php +++ b/tests/Unit/Auth/CanDirectiveTest.php @@ -8,7 +8,9 @@ use Tests\Utils\Models\User; use Tests\Utils\Policies\UserPolicy; -/** TODO remove with v7 */ +/** + * TODO remove with v7. + */ final class CanDirectiveTest extends TestCase { public function testThrowsIfNotAuthorized(): void diff --git a/tests/Utils/Bind/SpyCallableClassBinding.php b/tests/Utils/Bind/SpyCallableClassBinding.php index 28b17a82f4..36828e4081 100644 --- a/tests/Utils/Bind/SpyCallableClassBinding.php +++ b/tests/Utils/Bind/SpyCallableClassBinding.php @@ -5,7 +5,9 @@ use Nuwave\Lighthouse\Bind\BindDefinition; use PHPUnit\Framework\Assert; -/** @template TReturn */ +/** + * @template TReturn + */ final class SpyCallableClassBinding { private mixed $value = null; diff --git a/tests/Utils/MockableFoo.php b/tests/Utils/MockableFoo.php index ae3b424275..e796940ecd 100644 --- a/tests/Utils/MockableFoo.php +++ b/tests/Utils/MockableFoo.php @@ -2,7 +2,9 @@ namespace Tests\Utils; -/** Interface for mocking objects with a bar method. */ +/** + * Interface for mocking objects with a bar method. + */ interface MockableFoo { public function bar(mixed ...$args): mixed; diff --git a/tests/Utils/Models/User/UserBuilder.php b/tests/Utils/Models/User/UserBuilder.php index a67a83f874..fba6d76449 100644 --- a/tests/Utils/Models/User/UserBuilder.php +++ b/tests/Utils/Models/User/UserBuilder.php @@ -5,7 +5,9 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; -/** @extends \Illuminate\Database\Eloquent\Builder<\Tests\Utils\Models\User> */ +/** + * @extends \Illuminate\Database\Eloquent\Builder<\Tests\Utils\Models\User> + */ final class UserBuilder extends Builder { /** @param array{company: string} $args */ diff --git a/tests/Utils/Queries/MissingInvoke.php b/tests/Utils/Queries/MissingInvoke.php index 49f5075d89..091d7d434d 100644 --- a/tests/Utils/Queries/MissingInvoke.php +++ b/tests/Utils/Queries/MissingInvoke.php @@ -2,5 +2,7 @@ namespace Tests\Utils\Queries; -/** This class intentionally misses a resolver function __invoke(). */ +/** + * This class intentionally misses a resolver function __invoke(). + */ final class MissingInvoke {} From 238258eefbe67fd7c81d140e6500a6c5c86070e4 Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 20:41:20 +0100 Subject: [PATCH 28/31] Fix https://github.com/nuwave/lighthouse/pull/2426#discussion_r2794589995 --- src/Execution/Arguments/UpsertModel.php | 2 +- .../Directives/UpsertManyDirectiveTest.php | 77 +++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/Execution/Arguments/UpsertModel.php b/src/Execution/Arguments/UpsertModel.php index 7a37814ed1..4b001e9732 100644 --- a/src/Execution/Arguments/UpsertModel.php +++ b/src/Execution/Arguments/UpsertModel.php @@ -107,7 +107,7 @@ protected function retrieveID(Model $model, ArgumentSet $args) /** @return \Illuminate\Database\Eloquent\Builder<\Illuminate\Database\Eloquent\Model> */ protected function queryBuilder(Model $model): EloquentBuilder { - return $this->parentRelation?->getQuery() + return $this->parentRelation?->getQuery()->clone() ?? $model->newQuery(); } } diff --git a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php index dfa82c1869..e582b9c9ee 100644 --- a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php @@ -424,6 +424,83 @@ public function testNestedUpsertManyByIdentifyingColumn(): void $this->assertSame(2, Task::count()); } + public function testNestedUpsertManyByIdentifyingColumnOnMultipleExistingModels(): void + { + $user = factory(User::class)->create(); + + $firstExistingTask = factory(Task::class)->make(); + $firstExistingTask->name = 'first-existing-task'; + $firstExistingTask->difficulty = 1; + $firstExistingTask->user()->associate($user); + $firstExistingTask->save(); + + $secondExistingTask = factory(Task::class)->make(); + $secondExistingTask->name = 'second-existing-task'; + $secondExistingTask->difficulty = 1; + $secondExistingTask->user()->associate($user); + $secondExistingTask->save(); + + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + updateUser(input: UpdateUserInput! @spread): User @update + } + + type Task { + id: Int + name: String! + difficulty: Int + } + + type User { + id: Int + tasks: [Task!]! @hasMany + } + + input UpdateUserInput { + id: Int + tasks: [UpdateTaskInput!] @upsertMany(relation: "tasks", identifyingColumns: ["name"]) + } + + input UpdateTaskInput { + name: String! + difficulty: Int + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<id} + tasks: [ + { + name: "first-existing-task" + difficulty: 2 + } + { + name: "second-existing-task" + difficulty: 3 + } + ] + }) { + id + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'updateUser' => [ + 'id' => $user->id, + ], + ], + ]); + + $firstExistingTask->refresh(); + $secondExistingTask->refresh(); + + $this->assertSame(2, $firstExistingTask->difficulty); + $this->assertSame(3, $secondExistingTask->difficulty); + $this->assertSame(2, Task::count()); + } + public function testDirectUpsertManyByIdentifyingColumnsRequiresAllConfiguredColumns(): void { $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' From da3378a297c9dfe880e2b59e8aa80305a51f7257 Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 20:52:35 +0100 Subject: [PATCH 29/31] Use safe array_flip in UpsertModel --- src/Execution/Arguments/UpsertModel.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Execution/Arguments/UpsertModel.php b/src/Execution/Arguments/UpsertModel.php index 4b001e9732..5f84a32898 100644 --- a/src/Execution/Arguments/UpsertModel.php +++ b/src/Execution/Arguments/UpsertModel.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; +use function Safe\array_flip; class UpsertModel implements ArgResolver { From d486410e48ad9845310246f9fd669eeabdf02f3c Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 19:53:20 +0000 Subject: [PATCH 30/31] Apply php-cs-fixer changes --- src/Execution/Arguments/UpsertModel.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Execution/Arguments/UpsertModel.php b/src/Execution/Arguments/UpsertModel.php index 5f84a32898..11b287757b 100644 --- a/src/Execution/Arguments/UpsertModel.php +++ b/src/Execution/Arguments/UpsertModel.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; + use function Safe\array_flip; class UpsertModel implements ArgResolver From 4661ada78e5eef6033c45e5deaf3a5e650781359 Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 11 Feb 2026 21:21:58 +0100 Subject: [PATCH 31/31] Remove remaining nested relation-scope upsert wiring --- src/Execution/Arguments/UpsertModel.php | 15 ++------------- src/Schema/Directives/UpsertDirective.php | 1 - src/Schema/Directives/UpsertManyDirective.php | 1 - 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/src/Execution/Arguments/UpsertModel.php b/src/Execution/Arguments/UpsertModel.php index 11b287757b..4e7f3589c5 100644 --- a/src/Execution/Arguments/UpsertModel.php +++ b/src/Execution/Arguments/UpsertModel.php @@ -3,9 +3,7 @@ namespace Nuwave\Lighthouse\Execution\Arguments; use GraphQL\Error\Error; -use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\Relation; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; use function Safe\array_flip; @@ -22,8 +20,6 @@ public function __construct( callable $previous, /** @var array|null */ protected ?array $identifyingColumns = null, - /** @var \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null $parentRelation */ - protected ?Relation $parentRelation = null, ) { $this->previous = $previous; } @@ -41,7 +37,7 @@ public function __invoke($model, $args): mixed $identifyingColumns = $this->identifyingColumnValues($args, $this->identifyingColumns) ?? throw new Error(self::MISSING_IDENTIFYING_COLUMNS_FOR_UPSERT); - $existingModel = $this->queryBuilder($model)->firstWhere($identifyingColumns); + $existingModel = $model->newQuery()->firstWhere($identifyingColumns); if ($existingModel !== null) { $model = $existingModel; @@ -51,7 +47,7 @@ public function __invoke($model, $args): mixed if ($existingModel === null) { $id = $this->retrieveID($model, $args); if ($id) { - $existingModel = $this->queryBuilder($model)->find($id); + $existingModel = $model->newQuery()->find($id); if ($existingModel !== null) { $model = $existingModel; } @@ -105,11 +101,4 @@ protected function retrieveID(Model $model, ArgumentSet $args) return null; } - - /** @return \Illuminate\Database\Eloquent\Builder<\Illuminate\Database\Eloquent\Model> */ - protected function queryBuilder(Model $model): EloquentBuilder - { - return $this->parentRelation?->getQuery()->clone() - ?? $model->newQuery(); - } } diff --git a/src/Schema/Directives/UpsertDirective.php b/src/Schema/Directives/UpsertDirective.php index 02faf1d81e..526ee860bc 100644 --- a/src/Schema/Directives/UpsertDirective.php +++ b/src/Schema/Directives/UpsertDirective.php @@ -52,7 +52,6 @@ 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 77730e6aa1..bc013293ce 100644 --- a/src/Schema/Directives/UpsertManyDirective.php +++ b/src/Schema/Directives/UpsertManyDirective.php @@ -52,7 +52,6 @@ protected function makeExecutionFunction(?Relation $parentRelation = null): call return new UpsertModel( new SaveModel($parentRelation), $this->directiveArgValue('identifyingColumns'), - $parentRelation, ); }