diff --git a/CHANGELOG.md b/CHANGELOG.md index c489894301..36bad78878 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 + +- Specify identifying columns on nested mutation upserts with `@upsert` and `@upsertMany` https://github.com/nuwave/lighthouse/pull/2426 + ## v6.64.3 ### Fixed diff --git a/docs/master/api-reference/directives.md b/docs/master/api-reference/directives.md index 097ba943c4..7f70539411 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. + Optional, by default `id` or the primary key of the model are used. + """ + 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,16 @@ 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 { + 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 +4074,12 @@ directive @upsertMany( """ model: String + """ + Specify the columns by which to upsert the model. + Optional, by default `id` or the primary key of the model are used. + """ + 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 +4102,22 @@ 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! +} +``` + +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 9b58e3bade..b0942e9d68 100644 --- a/docs/master/eloquent/getting-started.md +++ b/docs/master/eloquent/getting-started.md @@ -260,6 +260,17 @@ 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"]) +} +``` + +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/src/Execution/Arguments/UpsertModel.php b/src/Execution/Arguments/UpsertModel.php index bc1984888a..4e7f3589c5 100644 --- a/src/Execution/Arguments/UpsertModel.php +++ b/src/Execution/Arguments/UpsertModel.php @@ -2,17 +2,25 @@ namespace Nuwave\Lighthouse\Execution\Arguments; +use GraphQL\Error\Error; use Illuminate\Database\Eloquent\Model; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; +use function Safe\array_flip; + class UpsertModel implements ArgResolver { + public const MISSING_IDENTIFYING_COLUMNS_FOR_UPSERT = 'All configured identifying columns must be present and non-null for upsert.'; + /** @var callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver */ protected $previous; /** @param callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver $previous */ - public function __construct(callable $previous) - { + public function __construct( + callable $previous, + /** @var array|null */ + protected ?array $identifyingColumns = null, + ) { $this->previous = $previous; } @@ -22,21 +30,58 @@ public function __construct(callable $previous) */ 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; - $id = $this->retrieveID($model, $args); - if ($id) { - $existingModel = $model->newQuery() - ->find($id); + if ($this->identifyingColumns) { + $identifyingColumns = $this->identifyingColumnValues($args, $this->identifyingColumns) + ?? throw new Error(self::MISSING_IDENTIFYING_COLUMNS_FOR_UPSERT); + + $existingModel = $model->newQuery()->firstWhere($identifyingColumns); if ($existingModel !== null) { $model = $existingModel; } } + if ($existingModel === null) { + $id = $this->retrieveID($model, $args); + if ($id) { + $existingModel = $model->newQuery()->find($id); + if ($existingModel !== null) { + $model = $existingModel; + } + } + } + return ($this->previous)($model, $args); } + /** + * @param array $identifyingColumns + * + * @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 $identifyingValue) { + if ($identifyingValue === null) { + return null; + } + } + + return $identifyingValues; + } + /** @return mixed The value of the ID or null */ protected function retrieveID(Model $model, ArgumentSet $args) { diff --git a/src/Schema/Directives/UpsertDirective.php b/src/Schema/Directives/UpsertDirective.php index 38c16cb68c..526ee860bc 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\InputObjectTypeDefinitionNode; +use GraphQL\Language\AST\InputValueDefinitionNode; +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 { @@ -21,6 +31,12 @@ public static function definition(): string """ model: String + """ + Specify the columns by which to upsert the model. + Optional, by default `id` or the primary key of the model are used. + """ + 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 +49,43 @@ 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'), + ); + } + + 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 ($identifyingColumns === []) { + 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 7cd48f9fca..bc013293ce 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\InputObjectTypeDefinitionNode; +use GraphQL\Language\AST\InputValueDefinitionNode; +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 { @@ -21,6 +31,12 @@ public static function definition(): string """ model: String + """ + Specify the columns by which to upsert the model. + Optional, by default `id` or the primary key of the model are used. + """ + 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 +49,43 @@ 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'), + ); + } + + 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 ($identifyingColumns === []) { + 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 a46693ed90..49324c7c41 100644 --- a/tests/Integration/Schema/Directives/UpsertDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertDirectiveTest.php @@ -4,8 +4,11 @@ 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; @@ -13,10 +16,13 @@ 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(); + $task = factory(Task::class)->make(); $this->assertInstanceOf(Task::class, $task); + $task->user()->associate($user); $task->id = 1; $task->name = 'old'; $task->save(); @@ -202,6 +208,348 @@ interface IUser ]); } + public function testDirectUpsertByIdentifyingColumn(): void + { + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + email: String! + name: String! + } + + type Mutation { + upsertUser(name: String!, email: String!): User @upsert(identifyingColumns: ["email"]) + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + upsertUser( + email: "foo@te.st" + name: "bar" + ) { + name + email + } + } + GRAPHQL)->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 */ <<<'GRAPHQL' + mutation { + upsertUser( + email: "foo@te.st" + name: "foo" + ) { + name + email + } + } + GRAPHQL)->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)->make(); + $company->id = 1; + $company->save(); + + $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"]) + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + upsertUser( + email: "foo@te.st" + name: "bar" + company_id: 1 + ) { + name + email + company_id + } + } + GRAPHQL)->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 */ <<<'GRAPHQL' + mutation { + upsertUser( + email: "bar@te.st" + name: "bar" + company_id: 1 + ) { + name + email + company_id + } + } + GRAPHQL)->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 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 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' + 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 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..e582b9c9ee 100644 --- a/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php +++ b/tests/Integration/Schema/Directives/UpsertManyDirectiveTest.php @@ -4,6 +4,8 @@ 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\Task; @@ -13,10 +15,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 +218,317 @@ 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 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 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 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' + 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 static function resolveType(): Type { $typeRegistry = Container::getInstance()->make(TypeRegistry::class);