Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
f7a82a9
WIP: adding identifyingColumns directive to upserts
gavg-vaioni Jul 27, 2023
0e63ba6
Apply php-cs-fixer changes
GavG Jul 27, 2023
0472f03
update changelog
gavg-vaioni Jul 28, 2023
0a753b9
Merge branch 'upsert_fields_directive' of github.com:GavG/lighthouse …
gavg-vaioni Jul 28, 2023
a842692
Merge remote-tracking branch 'origin/master' into upsert_fields_direc…
spawnia Feb 10, 2026
faa6323
Normalize GraphQL test literals to #2748 after merging master
spawnia Feb 10, 2026
f0eaca6
Continue work
spawnia Feb 11, 2026
6e6aa17
Scope nested upserts to parent relations and expand coverage
spawnia Feb 11, 2026
4d96407
Address review feedback on nested upsert identifying columns
spawnia Feb 11, 2026
223315d
Address remaining upsert review threads
spawnia Feb 11, 2026
e9c0e5c
Apply php-cs-fixer changes
spawnia Feb 11, 2026
403b081
review
spawnia Feb 11, 2026
c239147
Revert "review"
spawnia Feb 11, 2026
37f2fc7
Revert "Scope nested upserts to parent relations and expand coverage"
spawnia Feb 11, 2026
0d741c4
Revert "Revert "Scope nested upserts to parent relations and expand c…
spawnia Feb 11, 2026
61932b4
Revert "Revert "review""
spawnia Feb 11, 2026
e333344
Move nested relation-scope changes out of PR #2426
spawnia Feb 11, 2026
9f2d6a8
Apply php-cs-fixer changes
spawnia Feb 11, 2026
4178afb
fix
spawnia Feb 11, 2026
0455025
Merge remote-tracking branch 'GavG/upsert_fields_directive' into upse…
spawnia Feb 11, 2026
f3ff39e
fix
spawnia Feb 11, 2026
d41c134
Validate empty identifyingColumns during schema build
spawnia Feb 11, 2026
d4bd086
Remove unrelated-model upsert guard and tests
spawnia Feb 11, 2026
820dae7
Simplify empty identifyingColumns checks in upsert directives
spawnia Feb 11, 2026
db59215
@var
spawnia Feb 11, 2026
61c8318
Clarify why Eloquent native upsert is not used
spawnia Feb 11, 2026
233998e
avoid double save
spawnia Feb 11, 2026
1cb73aa
Update CHANGELOG to remove 'Changed' section
spawnia Feb 11, 2026
641c313
Merge branch 'master' into upsert_fields_directive
spawnia Feb 11, 2026
432622d
review
spawnia Feb 11, 2026
431f6ef
Apply php-cs-fixer changes
spawnia Feb 11, 2026
2574d49
Merge branch 'master' into upsert_fields_directive
spawnia Feb 11, 2026
f54a7c4
Merge remote-tracking branch 'GavG/upsert_fields_directive' into upse…
spawnia Feb 11, 2026
238258e
Fix https://github.com/nuwave/lighthouse/pull/2426#discussion_r279458…
spawnia Feb 11, 2026
da3378a
Use safe array_flip in UpsertModel
spawnia Feb 11, 2026
d486410
Apply php-cs-fixer changes
spawnia Feb 11, 2026
4661ada
Remove remaining nested relation-scope upsert wiring
spawnia Feb 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions docs/master/api-reference/directives.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions docs/master/eloquent/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 52 additions & 7 deletions src/Execution/Arguments/UpsertModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>|null */
protected ?array $identifyingColumns = null,
) {
$this->previous = $previous;
}

Expand All @@ -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<int, string> $identifyingColumns
*
* @return array<string, mixed>|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)
{
Expand Down
57 changes: 55 additions & 2 deletions src/Schema/Directives/UpsertDirective.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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
Expand All @@ -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}`.");
}
}
}
57 changes: 55 additions & 2 deletions src/Schema/Directives/UpsertManyDirective.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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
Expand All @@ -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}`.");
}
}
}
Loading