From 537cd9b2d253615ff6d4dfc47ed3ebc330e5dd49 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 29 Jun 2026 10:59:18 +0200 Subject: [PATCH 01/47] Add PreSaveArgResolver and support @belongsTo/@hasOne on INPUT_FIELD_DEFINITION MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow explicit `@belongsTo` and `@hasOne` directives on input fields to control nested mutation resolution. `@belongsTo` implements `PreSaveArgResolver` so the FK is set on the parent model before save, avoiding NOT NULL constraint violations. 🤖 Generated with Claude Code --- src/Execution/Arguments/ResolveNested.php | 24 +++++++++++++++++--- src/Schema/Directives/BelongsToDirective.php | 20 ++++++++++++++-- src/Schema/Directives/HasOneDirective.php | 19 ++++++++++++++-- src/Support/Contracts/PreSaveArgResolver.php | 5 ++++ 4 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 src/Support/Contracts/PreSaveArgResolver.php diff --git a/src/Execution/Arguments/ResolveNested.php b/src/Execution/Arguments/ResolveNested.php index ed1890dcaf..79302fcb2c 100644 --- a/src/Execution/Arguments/ResolveNested.php +++ b/src/Execution/Arguments/ResolveNested.php @@ -3,6 +3,7 @@ namespace Nuwave\Lighthouse\Execution\Arguments; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; +use Nuwave\Lighthouse\Support\Contracts\PreSaveArgResolver; class ResolveNested implements ArgResolver { @@ -25,13 +26,30 @@ public function __invoke(mixed $root, $args): mixed [$nestedArgs, $regularArgs] = ($this->argPartitioner)($args, $root); assert($nestedArgs instanceof ArgumentSet); + $preSaveArgs = new ArgumentSet(); + $postSaveArgs = new ArgumentSet(); + foreach ($nestedArgs->arguments as $name => $nested) { + if ($nested->resolver instanceof PreSaveArgResolver) { + $preSaveArgs->arguments[$name] = $nested; + } else { + $postSaveArgs->arguments[$name] = $nested; + } + } + + foreach ($preSaveArgs->arguments as $nested) { + $resolver = $nested->resolver; + assert($resolver !== null, 'Resolver must be set because we partitioned for it.'); + $resolver($root, $nested->value); + } + if ($this->previous !== null) { $root = ($this->previous)($root, $regularArgs); } - foreach ($nestedArgs->arguments as $nested) { - // @phpstan-ignore-next-line we know the resolver is there because we partitioned for it - ($nested->resolver)($root, $nested->value); + foreach ($postSaveArgs->arguments as $nested) { + $resolver = $nested->resolver; + assert($resolver !== null, 'Resolver must be set because we partitioned for it.'); + $resolver($root, $nested->value); } return $root; diff --git a/src/Schema/Directives/BelongsToDirective.php b/src/Schema/Directives/BelongsToDirective.php index 0b0a61ca3b..057d27c15b 100644 --- a/src/Schema/Directives/BelongsToDirective.php +++ b/src/Schema/Directives/BelongsToDirective.php @@ -2,13 +2,20 @@ namespace Nuwave\Lighthouse\Schema\Directives; -class BelongsToDirective extends RelationDirective +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Nuwave\Lighthouse\Execution\Arguments\NestedBelongsTo; +use Nuwave\Lighthouse\Execution\Arguments\ResolveNested; +use Nuwave\Lighthouse\Support\Contracts\PreSaveArgResolver; + +class BelongsToDirective extends RelationDirective implements PreSaveArgResolver { public static function definition(): string { return /** @lang GraphQL */ <<<'GRAPHQL' """ Resolves a field through the Eloquent `BelongsTo` relationship. +When used on an input field, handles nested mutations for the BelongsTo relationship. """ directive @belongsTo( """ @@ -21,7 +28,16 @@ public static function definition(): string Apply scopes to the underlying query. """ scopes: [String!] -) on FIELD_DEFINITION +) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION GRAPHQL; } + + public function __invoke(mixed $root, mixed $value): void + { + assert($root instanceof Model); + $relationName = $this->directiveArgValue('relation') ?? $this->nodeName(); + $relation = $root->{$relationName}(); + assert($relation instanceof BelongsTo); + (new ResolveNested(new NestedBelongsTo($relation)))($root, $value); + } } diff --git a/src/Schema/Directives/HasOneDirective.php b/src/Schema/Directives/HasOneDirective.php index 2daede7831..621782a9cc 100644 --- a/src/Schema/Directives/HasOneDirective.php +++ b/src/Schema/Directives/HasOneDirective.php @@ -2,13 +2,19 @@ namespace Nuwave\Lighthouse\Schema\Directives; -class HasOneDirective extends RelationDirective +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasOne; +use Nuwave\Lighthouse\Execution\Arguments\NestedOneToOne; +use Nuwave\Lighthouse\Support\Contracts\ArgResolver; + +class HasOneDirective extends RelationDirective implements ArgResolver { public static function definition(): string { return /** @lang GraphQL */ <<<'GRAPHQL' """ Corresponds to [the Eloquent relationship HasOne](https://laravel.com/docs/eloquent-relationships#one-to-one). +When used on an input field, handles nested mutations for the HasOne relationship. """ directive @hasOne( """ @@ -21,7 +27,16 @@ public static function definition(): string Apply scopes to the underlying query. """ scopes: [String!] -) on FIELD_DEFINITION +) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION GRAPHQL; } + + public function __invoke(mixed $root, mixed $value): void + { + assert($root instanceof Model); + $relationName = $this->directiveArgValue('relation') ?? $this->nodeName(); + $relation = $root->{$relationName}(); + assert($relation instanceof HasOne); + (new NestedOneToOne($relationName))($root, $value); + } } diff --git a/src/Support/Contracts/PreSaveArgResolver.php b/src/Support/Contracts/PreSaveArgResolver.php new file mode 100644 index 0000000000..755a12d1c7 --- /dev/null +++ b/src/Support/Contracts/PreSaveArgResolver.php @@ -0,0 +1,5 @@ + Date: Mon, 29 Jun 2026 12:36:07 +0200 Subject: [PATCH 02/47] Fix pre-save resolvers for update/upsert, add MorphTo guard, add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-save resolvers now run after $previous (model is identified and saved), then trigger an additional save if they modified the model. This makes @belongsTo on INPUT_FIELD_DEFINITION work correctly for all mutation flows (create, update, upsert), not just create. Also rejects MorphTo relations in @belongsTo with a clear assertion message, since MorphTo requires separate handling via @morphTo. 🤖 Generated with Claude Code --- src/Execution/Arguments/ResolveNested.php | 9 +- src/Schema/Directives/BelongsToDirective.php | 6 +- .../BelongsToDirectiveOnInputFieldTest.php | 317 ++++++++++++++++++ .../HasOneDirectiveOnInputFieldTest.php | 208 ++++++++++++ 4 files changed, 537 insertions(+), 3 deletions(-) create mode 100644 tests/Integration/Execution/MutationExecutor/BelongsToDirectiveOnInputFieldTest.php create mode 100644 tests/Integration/Execution/MutationExecutor/HasOneDirectiveOnInputFieldTest.php diff --git a/src/Execution/Arguments/ResolveNested.php b/src/Execution/Arguments/ResolveNested.php index 79302fcb2c..510761c805 100644 --- a/src/Execution/Arguments/ResolveNested.php +++ b/src/Execution/Arguments/ResolveNested.php @@ -2,6 +2,7 @@ namespace Nuwave\Lighthouse\Execution\Arguments; +use Illuminate\Database\Eloquent\Model; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; use Nuwave\Lighthouse\Support\Contracts\PreSaveArgResolver; @@ -36,14 +37,18 @@ public function __invoke(mixed $root, $args): mixed } } + if ($this->previous !== null) { + $root = ($this->previous)($root, $regularArgs); + } + foreach ($preSaveArgs->arguments as $nested) { $resolver = $nested->resolver; assert($resolver !== null, 'Resolver must be set because we partitioned for it.'); $resolver($root, $nested->value); } - if ($this->previous !== null) { - $root = ($this->previous)($root, $regularArgs); + if ($preSaveArgs->arguments !== [] && $root instanceof Model) { + $root->save(); } foreach ($postSaveArgs->arguments as $nested) { diff --git a/src/Schema/Directives/BelongsToDirective.php b/src/Schema/Directives/BelongsToDirective.php index 057d27c15b..047bdce325 100644 --- a/src/Schema/Directives/BelongsToDirective.php +++ b/src/Schema/Directives/BelongsToDirective.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphTo; use Nuwave\Lighthouse\Execution\Arguments\NestedBelongsTo; use Nuwave\Lighthouse\Execution\Arguments\ResolveNested; use Nuwave\Lighthouse\Support\Contracts\PreSaveArgResolver; @@ -37,7 +38,10 @@ public function __invoke(mixed $root, mixed $value): void assert($root instanceof Model); $relationName = $this->directiveArgValue('relation') ?? $this->nodeName(); $relation = $root->{$relationName}(); - assert($relation instanceof BelongsTo); + assert( + $relation instanceof BelongsTo && ! $relation instanceof MorphTo, + "Use @morphTo for MorphTo relations, @belongsTo does not support them: {$relationName}.", + ); (new ResolveNested(new NestedBelongsTo($relation)))($root, $value); } } diff --git a/tests/Integration/Execution/MutationExecutor/BelongsToDirectiveOnInputFieldTest.php b/tests/Integration/Execution/MutationExecutor/BelongsToDirectiveOnInputFieldTest.php new file mode 100644 index 0000000000..57e6ea65db --- /dev/null +++ b/tests/Integration/Execution/MutationExecutor/BelongsToDirectiveOnInputFieldTest.php @@ -0,0 +1,317 @@ +create(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createTask(input: { + name: "foo" + user: { + connect: 1 + } + }) { + id + name + user { + id + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createTask' => [ + 'id' => '1', + 'name' => 'foo', + 'user' => [ + 'id' => '1', + ], + ], + ], + ]); + } + + public function testCreateWithNewBelongsTo(): void + { + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createTask(input: { + name: "foo" + user: { + create: { + name: "New User" + } + } + }) { + id + name + user { + id + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createTask' => [ + 'id' => '1', + 'name' => 'foo', + 'user' => [ + 'id' => '1', + ], + ], + ], + ]); + } + + public function testUpdateWithConnectBelongsTo(): void + { + $task = factory(Task::class)->create(); + $user = factory(User::class)->create(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + updateTask(input: { + id: 1 + name: "updated" + user: { + connect: 1 + } + }) { + id + name + user { + id + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'updateTask' => [ + 'id' => '1', + 'name' => 'updated', + 'user' => [ + 'id' => '1', + ], + ], + ], + ]); + } + + public function testUpdateWithCreateBelongsTo(): void + { + $task = factory(Task::class)->create(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + updateTask(input: { + id: 1 + name: "updated" + user: { + create: { + name: "New User" + } + } + }) { + id + name + user { + id + name + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'updateTask' => [ + 'id' => '1', + 'name' => 'updated', + 'user' => [ + 'id' => '1', + 'name' => 'New User', + ], + ], + ], + ]); + } + + public function testUpdateAndDisconnectBelongsTo(): void + { + $user = factory(User::class)->create(); + $task = factory(Task::class)->make(); + $task->user()->associate($user); + $task->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + updateTask(input: { + id: 1 + name: "updated" + user: { + disconnect: true + } + }) { + id + name + user { + id + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'updateTask' => [ + 'id' => '1', + 'name' => 'updated', + 'user' => null, + ], + ], + ]); + } + + public function testUpsertCreatesWithConnectBelongsTo(): void + { + $user = factory(User::class)->create(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + upsertTask(input: { + name: "foo" + user: { + connect: 1 + } + }) { + id + name + user { + id + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'upsertTask' => [ + 'id' => '1', + 'name' => 'foo', + 'user' => [ + 'id' => '1', + ], + ], + ], + ]); + } + + public function testUpsertUpdatesWithConnectBelongsTo(): void + { + $task = factory(Task::class)->create(); + $user = factory(User::class)->create(); + + $this->graphQL(/** @lang GraphQL */ <<id} + name: "updated" + user: { + connect: {$user->id} + } + }) { + id + name + user { + id + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'upsertTask' => [ + 'id' => "{$task->id}", + 'name' => 'updated', + 'user' => [ + 'id' => "{$user->id}", + ], + ], + ], + ]); + } +} diff --git a/tests/Integration/Execution/MutationExecutor/HasOneDirectiveOnInputFieldTest.php b/tests/Integration/Execution/MutationExecutor/HasOneDirectiveOnInputFieldTest.php new file mode 100644 index 0000000000..c4d9aedbd3 --- /dev/null +++ b/tests/Integration/Execution/MutationExecutor/HasOneDirectiveOnInputFieldTest.php @@ -0,0 +1,208 @@ +graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createTask(input: { + name: "foo" + post: { + create: { + title: "bar" + } + } + }) { + id + name + post { + id + title + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createTask' => [ + 'id' => '1', + 'name' => 'foo', + 'post' => [ + 'id' => '1', + 'title' => 'bar', + ], + ], + ], + ]); + } + + public function testUpdateWithCreateHasOne(): void + { + $task = factory(Task::class)->create(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + updateTask(input: { + id: 1 + name: "updated" + post: { + create: { + title: "new post" + } + } + }) { + id + name + post { + id + title + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'updateTask' => [ + 'id' => '1', + 'name' => 'updated', + 'post' => [ + 'id' => '1', + 'title' => 'new post', + ], + ], + ], + ]); + } + + public function testUpdateWithUpdateHasOne(): void + { + $task = factory(Task::class)->create(); + $post = factory(Post::class)->make(); + $post->title = 'original'; + $post->task()->associate($task); + $post->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + updateTask(input: { + id: 1 + post: { + update: { + id: 1 + title: "changed" + } + } + }) { + id + post { + id + title + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'updateTask' => [ + 'id' => '1', + 'post' => [ + 'id' => '1', + 'title' => 'changed', + ], + ], + ], + ]); + } + + public function testUpdateWithDeleteHasOne(): void + { + $task = factory(Task::class)->create(); + $post = factory(Post::class)->make(); + $post->task()->associate($task); + $post->save(); + + $this->graphQL(/** @lang GraphQL */ <<id} + } + }) { + id + post { + id + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'updateTask' => [ + 'id' => '1', + 'post' => null, + ], + ], + ]); + + $this->assertNull(Post::find($post->id)); + } +} From 89fd450f848c7b11c27f8e75c7853ca1886af883 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 29 Jun 2026 12:36:24 +0200 Subject: [PATCH 03/47] Fix dev commands: use docker compose run instead of exec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- .ai/AGENTS.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.ai/AGENTS.md b/.ai/AGENTS.md index 596f0e557d..ceabf19e9e 100644 --- a/.ai/AGENTS.md +++ b/.ai/AGENTS.md @@ -23,11 +23,13 @@ make bench # Run PHPBench benchmarks ### Running a Single Test ```bash -docker compose exec php vendor/bin/phpunit --filter=TestClassName -docker compose exec php vendor/bin/phpunit --filter=testMethodName -docker compose exec php vendor/bin/phpunit tests/Unit/Path/To/TestFile.php +docker compose run --rm php vendor/bin/phpunit --filter=TestClassName +docker compose run --rm php vendor/bin/phpunit --filter=testMethodName +docker compose run --rm php vendor/bin/phpunit tests/Unit/Path/To/TestFile.php ``` +The `php` service is not long-running — use `docker compose run --rm` instead of `docker compose exec`. + ## Architecture ### Entry Points From 053d4429d50a6747f5949ab53e42779bb8bb98ff Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 29 Jun 2026 14:41:13 +0200 Subject: [PATCH 04/47] Handle PreSaveArgResolver inside SaveModel to prevent NOT NULL violations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PreSaveArgResolver args (like @belongsTo on INPUT_FIELD_DEFINITION) must set foreign keys before the model is persisted. Previously ResolveNested ran them after SaveModel and compensated with a second save — breaking NOT NULL FK columns on INSERT. Now SaveModel extracts and invokes PreSaveArgResolver args alongside implicit BelongsTo, so both paths are equivalent: FK is set before the single save. https://github.com/nuwave/lighthouse/pull/2777#discussion_r3491248340 https://github.com/nuwave/lighthouse/pull/2777#discussion_r3491248379 🤖 Generated with Claude Code --- src/Execution/Arguments/ArgPartitioner.php | 16 ++++++- src/Execution/Arguments/ResolveNested.php | 24 +--------- src/Execution/Arguments/SaveModel.php | 9 ++++ .../BelongsToDirectiveOnInputFieldTest.php | 48 +++++++++++++++++++ 4 files changed, 73 insertions(+), 24 deletions(-) diff --git a/src/Execution/Arguments/ArgPartitioner.php b/src/Execution/Arguments/ArgPartitioner.php index 5fc82bb892..170b39d1a2 100644 --- a/src/Execution/Arguments/ArgPartitioner.php +++ b/src/Execution/Arguments/ArgPartitioner.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\MorphToMany; use Nuwave\Lighthouse\Exceptions\DefinitionException; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; +use Nuwave\Lighthouse\Support\Contracts\PreSaveArgResolver; use Nuwave\Lighthouse\Support\Utils; class ArgPartitioner @@ -32,7 +33,20 @@ public static function nestedArgResolvers(ArgumentSet $argumentSet, mixed $root) return static::partition( $argumentSet, - static fn (string $name, Argument $argument): bool => isset($argument->resolver), + static fn (string $name, Argument $argument): bool => isset($argument->resolver) && ! $argument->resolver instanceof PreSaveArgResolver, + ); + } + + /** + * Partition arguments into those with PreSaveArgResolver and the rest. + * + * @return array{0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet} + */ + public static function preSaveResolvers(ArgumentSet $argumentSet): array + { + return static::partition( + $argumentSet, + static fn (string $name, Argument $argument): bool => $argument->resolver instanceof PreSaveArgResolver, ); } diff --git a/src/Execution/Arguments/ResolveNested.php b/src/Execution/Arguments/ResolveNested.php index 510761c805..21eba44e0c 100644 --- a/src/Execution/Arguments/ResolveNested.php +++ b/src/Execution/Arguments/ResolveNested.php @@ -2,9 +2,7 @@ namespace Nuwave\Lighthouse\Execution\Arguments; -use Illuminate\Database\Eloquent\Model; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; -use Nuwave\Lighthouse\Support\Contracts\PreSaveArgResolver; class ResolveNested implements ArgResolver { @@ -27,31 +25,11 @@ public function __invoke(mixed $root, $args): mixed [$nestedArgs, $regularArgs] = ($this->argPartitioner)($args, $root); assert($nestedArgs instanceof ArgumentSet); - $preSaveArgs = new ArgumentSet(); - $postSaveArgs = new ArgumentSet(); - foreach ($nestedArgs->arguments as $name => $nested) { - if ($nested->resolver instanceof PreSaveArgResolver) { - $preSaveArgs->arguments[$name] = $nested; - } else { - $postSaveArgs->arguments[$name] = $nested; - } - } - if ($this->previous !== null) { $root = ($this->previous)($root, $regularArgs); } - foreach ($preSaveArgs->arguments as $nested) { - $resolver = $nested->resolver; - assert($resolver !== null, 'Resolver must be set because we partitioned for it.'); - $resolver($root, $nested->value); - } - - if ($preSaveArgs->arguments !== [] && $root instanceof Model) { - $root->save(); - } - - foreach ($postSaveArgs->arguments as $nested) { + foreach ($nestedArgs->arguments as $nested) { $resolver = $nested->resolver; assert($resolver !== null, 'Resolver must be set because we partitioned for it.'); $resolver($root, $nested->value); diff --git a/src/Execution/Arguments/SaveModel.php b/src/Execution/Arguments/SaveModel.php index c80b634f94..23d2134c5b 100644 --- a/src/Execution/Arguments/SaveModel.php +++ b/src/Execution/Arguments/SaveModel.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\Relation; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; +use Nuwave\Lighthouse\Support\Contracts\PreSaveArgResolver; class SaveModel implements ArgResolver { @@ -36,6 +37,8 @@ public function __invoke($model, $args): Model BelongsTo::class, ); + [$preSave, $remaining] = ArgPartitioner::preSaveResolvers($remaining); + $argsToFill = $remaining->toArray(); // Use all the remaining attributes and fill the model @@ -59,6 +62,12 @@ public function __invoke($model, $args): Model $morphToResolver($model, $nestedOperations->value); } + foreach ($preSave->arguments as $nested) { + $resolver = $nested->resolver; + assert($resolver instanceof PreSaveArgResolver); + $resolver($model, $nested->value); + } + if ($this->parentRelation instanceof HasOneOrMany) { // If we are already resolving a nested create, we might // already have an instance of the parent relation available. diff --git a/tests/Integration/Execution/MutationExecutor/BelongsToDirectiveOnInputFieldTest.php b/tests/Integration/Execution/MutationExecutor/BelongsToDirectiveOnInputFieldTest.php index 57e6ea65db..1318fe6af4 100644 --- a/tests/Integration/Execution/MutationExecutor/BelongsToDirectiveOnInputFieldTest.php +++ b/tests/Integration/Execution/MutationExecutor/BelongsToDirectiveOnInputFieldTest.php @@ -20,10 +20,17 @@ final class BelongsToDirectiveOnInputFieldTest extends DBTestCase name: String! } + type Post { + id: ID! + title: String! + task: Task @belongsTo + } + type Mutation { createTask(input: CreateTaskInput! @spread): Task @create updateTask(input: UpdateTaskInput! @spread): Task @update upsertTask(input: UpsertTaskInput! @spread): Task @upsert + createPost(input: CreatePostInput! @spread): Post @create } input CreateTaskInput { @@ -78,6 +85,15 @@ final class BelongsToDirectiveOnInputFieldTest extends DBTestCase id: ID name: String } + + input CreatePostInput { + title: String! + task: CreateTaskRelation @belongsTo + } + + input CreateTaskRelation { + connect: ID + } GRAPHQL . self::PLACEHOLDER_QUERY; public function testCreateWithConnectBelongsTo(): void @@ -314,4 +330,36 @@ public function testUpsertUpdatesWithConnectBelongsTo(): void ], ]); } + + public function testCreateWithNotNullForeignKeyConnect(): void + { + $task = factory(Task::class)->create(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createPost(input: { + title: "My Post" + task: { + connect: 1 + } + }) { + id + title + task { + id + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createPost' => [ + 'id' => '1', + 'title' => 'My Post', + 'task' => [ + 'id' => '1', + ], + ], + ], + ]); + } } From a7279870be14e88075813772a24464de6831410b Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 29 Jun 2026 15:06:08 +0200 Subject: [PATCH 05/47] Address self-review findings: null guard, assert messages, deep nesting, test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skip null-valued PreSaveArgResolver args to prevent TypeError - Add descriptive messages to all assert() calls - Wrap HasOneDirective with ResolveNested for deep nesting support - Add test for mismatched field name using relation: argument - Add CHANGELOG entry - Document PreSaveArgResolver ordering contract 🤖 Generated with Claude Code --- CHANGELOG.md | 4 ++ src/Execution/Arguments/ArgPartitioner.php | 3 +- src/Execution/Arguments/SaveModel.php | 6 ++- src/Schema/Directives/BelongsToDirective.php | 2 +- src/Schema/Directives/HasOneDirective.php | 7 ++-- src/Support/Contracts/PreSaveArgResolver.php | 4 ++ .../BelongsToDirectiveOnInputFieldTest.php | 40 +++++++++++++++++++ 7 files changed, 60 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65a8992ad8..04123897c6 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 + +- Support `@belongsTo` and `@hasOne` on `INPUT_FIELD_DEFINITION` for explicit nested mutation handling https://github.com/nuwave/lighthouse/pull/2777 + ## v6.67.0 ### Changed diff --git a/src/Execution/Arguments/ArgPartitioner.php b/src/Execution/Arguments/ArgPartitioner.php index 170b39d1a2..e77ea920a9 100644 --- a/src/Execution/Arguments/ArgPartitioner.php +++ b/src/Execution/Arguments/ArgPartitioner.php @@ -33,7 +33,8 @@ public static function nestedArgResolvers(ArgumentSet $argumentSet, mixed $root) return static::partition( $argumentSet, - static fn (string $name, Argument $argument): bool => isset($argument->resolver) && ! $argument->resolver instanceof PreSaveArgResolver, + static fn (string $name, Argument $argument): bool => isset($argument->resolver) + && ! $argument->resolver instanceof PreSaveArgResolver, ); } diff --git a/src/Execution/Arguments/SaveModel.php b/src/Execution/Arguments/SaveModel.php index 23d2134c5b..748019ea92 100644 --- a/src/Execution/Arguments/SaveModel.php +++ b/src/Execution/Arguments/SaveModel.php @@ -63,8 +63,12 @@ public function __invoke($model, $args): Model } foreach ($preSave->arguments as $nested) { + if ($nested->value === null) { + continue; + } + $resolver = $nested->resolver; - assert($resolver instanceof PreSaveArgResolver); + assert($resolver instanceof PreSaveArgResolver, 'Resolver must be a PreSaveArgResolver because we partitioned for it.'); $resolver($model, $nested->value); } diff --git a/src/Schema/Directives/BelongsToDirective.php b/src/Schema/Directives/BelongsToDirective.php index 047bdce325..4d244ccb53 100644 --- a/src/Schema/Directives/BelongsToDirective.php +++ b/src/Schema/Directives/BelongsToDirective.php @@ -35,7 +35,7 @@ public static function definition(): string public function __invoke(mixed $root, mixed $value): void { - assert($root instanceof Model); + assert($root instanceof Model, 'BelongsToDirective is only used as an ArgResolver on Eloquent models.'); $relationName = $this->directiveArgValue('relation') ?? $this->nodeName(); $relation = $root->{$relationName}(); assert( diff --git a/src/Schema/Directives/HasOneDirective.php b/src/Schema/Directives/HasOneDirective.php index 621782a9cc..33f26865b8 100644 --- a/src/Schema/Directives/HasOneDirective.php +++ b/src/Schema/Directives/HasOneDirective.php @@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasOne; use Nuwave\Lighthouse\Execution\Arguments\NestedOneToOne; +use Nuwave\Lighthouse\Execution\Arguments\ResolveNested; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; class HasOneDirective extends RelationDirective implements ArgResolver @@ -33,10 +34,10 @@ public static function definition(): string public function __invoke(mixed $root, mixed $value): void { - assert($root instanceof Model); + assert($root instanceof Model, 'HasOneDirective is only used as an ArgResolver on Eloquent models.'); $relationName = $this->directiveArgValue('relation') ?? $this->nodeName(); $relation = $root->{$relationName}(); - assert($relation instanceof HasOne); - (new NestedOneToOne($relationName))($root, $value); + assert($relation instanceof HasOne, "Use @hasOne only for HasOne relations, not for: {$relationName}."); + (new ResolveNested(new NestedOneToOne($relationName)))($root, $value); } } diff --git a/src/Support/Contracts/PreSaveArgResolver.php b/src/Support/Contracts/PreSaveArgResolver.php index 755a12d1c7..ee8c9c7c52 100644 --- a/src/Support/Contracts/PreSaveArgResolver.php +++ b/src/Support/Contracts/PreSaveArgResolver.php @@ -2,4 +2,8 @@ namespace Nuwave\Lighthouse\Support\Contracts; +/** + * Resolvers implementing this interface are invoked before $model->save(), + * allowing them to set foreign keys on the parent model (e.g. BelongsTo). + */ interface PreSaveArgResolver extends ArgResolver {} diff --git a/tests/Integration/Execution/MutationExecutor/BelongsToDirectiveOnInputFieldTest.php b/tests/Integration/Execution/MutationExecutor/BelongsToDirectiveOnInputFieldTest.php index 1318fe6af4..cf8c4bb45a 100644 --- a/tests/Integration/Execution/MutationExecutor/BelongsToDirectiveOnInputFieldTest.php +++ b/tests/Integration/Execution/MutationExecutor/BelongsToDirectiveOnInputFieldTest.php @@ -89,6 +89,7 @@ final class BelongsToDirectiveOnInputFieldTest extends DBTestCase input CreatePostInput { title: String! task: CreateTaskRelation @belongsTo + owner: CreateUserRelation @belongsTo(relation: "user") } input CreateTaskRelation { @@ -362,4 +363,43 @@ public function testCreateWithNotNullForeignKeyConnect(): void ], ]); } + + public function testCreateWithMismatchedFieldNameUsingRelationArg(): void + { + $task = factory(Task::class)->create(); + $user = factory(User::class)->create(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createPost(input: { + title: "My Post" + task: { + connect: 1 + } + owner: { + connect: 1 + } + }) { + id + title + task { + id + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createPost' => [ + 'id' => '1', + 'title' => 'My Post', + 'task' => [ + 'id' => '1', + ], + ], + ], + ]); + + $post = \Tests\Utils\Models\Post::findOrFail(1); + $this->assertSame($user->id, $post->user_id); + } } From 12ec01c455c7b1f62d50b596bb74927d1b036c65 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 29 Jun 2026 15:44:01 +0200 Subject: [PATCH 06/47] simplify AGENTS.md --- .ai/AGENTS.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/.ai/AGENTS.md b/.ai/AGENTS.md index ceabf19e9e..2e106d9e0f 100644 --- a/.ai/AGENTS.md +++ b/.ai/AGENTS.md @@ -28,8 +28,6 @@ docker compose run --rm php vendor/bin/phpunit --filter=testMethodName docker compose run --rm php vendor/bin/phpunit tests/Unit/Path/To/TestFile.php ``` -The `php` service is not long-running — use `docker compose run --rm` instead of `docker compose exec`. - ## Architecture ### Entry Points From b8c5bfeb15cd8f7004b964f0cfd98e757e1c3035 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 29 Jun 2026 16:04:28 +0200 Subject: [PATCH 07/47] Rename ArgPartitioner methods for pre/post-save symmetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nestedArgResolvers → postSaveArgResolvers preSaveResolvers → preSaveArgResolvers 🤖 Generated with Claude Code --- src/Execution/Arguments/ArgPartitioner.php | 36 ++++++++++++++++------ src/Execution/Arguments/ResolveNested.php | 2 +- src/Execution/Arguments/SaveModel.php | 2 +- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/Execution/Arguments/ArgPartitioner.php b/src/Execution/Arguments/ArgPartitioner.php index e77ea920a9..280b2b6b5e 100644 --- a/src/Execution/Arguments/ArgPartitioner.php +++ b/src/Execution/Arguments/ArgPartitioner.php @@ -17,11 +17,14 @@ class ArgPartitioner { /** - * Partition the arguments into nested and regular. + * Partition the arguments into post-save and regular. * - * @return array<\Nuwave\Lighthouse\Execution\Arguments\ArgumentSet> + * @return array{ + * 0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, + * 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, + * } */ - public static function nestedArgResolvers(ArgumentSet $argumentSet, mixed $root): array + public static function postSaveArgResolvers(ArgumentSet $argumentSet, mixed $root): array { $model = $root instanceof Model ? new \ReflectionClass($root) @@ -33,17 +36,24 @@ public static function nestedArgResolvers(ArgumentSet $argumentSet, mixed $root) return static::partition( $argumentSet, - static fn (string $name, Argument $argument): bool => isset($argument->resolver) - && ! $argument->resolver instanceof PreSaveArgResolver, + static function (string $name, Argument $argument): bool { + $resolver = $argument->resolver; + + return $resolver !== null + && ! $resolver instanceof PreSaveArgResolver; + }, ); } /** - * Partition arguments into those with PreSaveArgResolver and the rest. + * Partition arguments into those with a pre-save resolver and the rest. * - * @return array{0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet} + * @return array{ + * 0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, + * 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, + * } */ - public static function preSaveResolvers(ArgumentSet $argumentSet): array + public static function preSaveArgResolvers(ArgumentSet $argumentSet): array { return static::partition( $argumentSet, @@ -73,7 +83,10 @@ public static function preSaveResolvers(ArgumentSet $argumentSet): array * ] * ] * - * @return array{0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet} + * @return array{ + * 0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, + * 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, + * } */ public static function relationMethods( ArgumentSet $argumentSet, @@ -158,7 +171,10 @@ protected static function attachNestedArgResolver(string $name, Argument &$argum * * @param callable(string $name, \Nuwave\Lighthouse\Execution\Arguments\Argument $argument): bool $predicate * - * @return array{0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet} + * @return array{ + * 0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, + * 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, + * } */ public static function partition(ArgumentSet $argumentSet, callable $predicate): array { diff --git a/src/Execution/Arguments/ResolveNested.php b/src/Execution/Arguments/ResolveNested.php index 21eba44e0c..4c2baf91e9 100644 --- a/src/Execution/Arguments/ResolveNested.php +++ b/src/Execution/Arguments/ResolveNested.php @@ -16,7 +16,7 @@ class ResolveNested implements ArgResolver public function __construct(?callable $previous = null, ?callable $argPartitioner = null) { $this->previous = $previous; - $this->argPartitioner = $argPartitioner ?? [ArgPartitioner::class, 'nestedArgResolvers']; + $this->argPartitioner = $argPartitioner ?? [ArgPartitioner::class, 'postSaveArgResolvers']; } /** @param ArgumentSet $args */ diff --git a/src/Execution/Arguments/SaveModel.php b/src/Execution/Arguments/SaveModel.php index 748019ea92..21e70a7798 100644 --- a/src/Execution/Arguments/SaveModel.php +++ b/src/Execution/Arguments/SaveModel.php @@ -37,7 +37,7 @@ public function __invoke($model, $args): Model BelongsTo::class, ); - [$preSave, $remaining] = ArgPartitioner::preSaveResolvers($remaining); + [$preSave, $remaining] = ArgPartitioner::preSaveArgResolvers($remaining); $argsToFill = $remaining->toArray(); From 8f1597388c28c6a346b6367dd68801f06d231707 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 29 Jun 2026 16:04:33 +0200 Subject: [PATCH 08/47] Reformat array{} PHPDoc types to multi-line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- src/GlobalId/Base64GlobalId.php | 5 ++++- src/GlobalId/GlobalId.php | 5 ++++- src/GlobalId/GlobalIdDirective.php | 5 ++++- src/Schema/Directives/BaseDirective.php | 15 ++++++++++++--- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/GlobalId/Base64GlobalId.php b/src/GlobalId/Base64GlobalId.php index 71837e8e3f..cb2fcaa31a 100644 --- a/src/GlobalId/Base64GlobalId.php +++ b/src/GlobalId/Base64GlobalId.php @@ -29,7 +29,10 @@ public function decode(string $globalID): array throw new GlobalIdException("Unexpectedly found more then 2 segments when decoding global id: {$globalID}."); } - /** @var array{0: string, 1: string} $parts */ + /** @var array{ + * 0: string, + * 1: string, + * } $parts */ return $parts; } diff --git a/src/GlobalId/GlobalId.php b/src/GlobalId/GlobalId.php index fddeadbf33..5debb2c238 100644 --- a/src/GlobalId/GlobalId.php +++ b/src/GlobalId/GlobalId.php @@ -13,7 +13,10 @@ public function encode(string $type, int|string $id): string; /** * Split a global id into the type and the id it contains. * - * @return array{0: string, 1: string} A tuple of [$type, $id], e.g. ['User', '123'] + * @return array{ + * 0: string, + * 1: string, + * } A tuple of [$type, $id], e.g. ['User', '123'] */ public function decode(string $globalID): array; diff --git a/src/GlobalId/GlobalIdDirective.php b/src/GlobalId/GlobalIdDirective.php index 8b24bb0766..7037ca5362 100644 --- a/src/GlobalId/GlobalIdDirective.php +++ b/src/GlobalId/GlobalIdDirective.php @@ -70,7 +70,10 @@ public function handleField(FieldValue $fieldValue): void /** * Decodes a global id given as an argument. * - * @return string|array{0: string, 1: string}|null + * @return string|array{ + * 0: string, + * 1: string, + * }|null */ public function sanitize(mixed $argumentValue): string|array|null { diff --git a/src/Schema/Directives/BaseDirective.php b/src/Schema/Directives/BaseDirective.php index b19a3434bd..9fc6e36a36 100644 --- a/src/Schema/Directives/BaseDirective.php +++ b/src/Schema/Directives/BaseDirective.php @@ -204,7 +204,10 @@ protected function namespaceClassName( * * This validates that exactly two non-empty parts are given, not that the method exists. * - * @return array{0: string, 1: string} Contains two entries: [string $className, string $methodName] + * @return array{ + * 0: string, + * 1: string, + * } Contains two entries: [string $className, string $methodName] */ protected function getMethodArgumentParts(string $argumentName): array { @@ -220,12 +223,18 @@ protected function getMethodArgumentParts(string $argumentName): array throw new DefinitionException("Directive '{$this->name()}' must have an argument '{$argumentName}' in the form 'ClassName@methodName' or 'ClassName'"); } - /** @var array{0: string, 1?: string} $argumentParts */ + /** @var array{ + * 0: string, + * 1?: string, + * } $argumentParts */ if (empty($argumentParts[1])) { $argumentParts[1] = '__invoke'; } - /** @var array{0: string, 1: string} $argumentParts */ + /** @var array{ + * 0: string, + * 1: string, + * } $argumentParts */ return $argumentParts; } From 714e0bf911ca5ee24d0364af8972826a4f322c44 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 29 Jun 2026 16:16:29 +0200 Subject: [PATCH 09/47] format, fix stan --- src/GlobalId/Base64GlobalId.php | 6 ++++-- src/Schema/Directives/BaseDirective.php | 13 ++++++++----- .../Unit/Execution/Arguments/ArgPartitionerTest.php | 2 +- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/GlobalId/Base64GlobalId.php b/src/GlobalId/Base64GlobalId.php index cb2fcaa31a..d78ad68da4 100644 --- a/src/GlobalId/Base64GlobalId.php +++ b/src/GlobalId/Base64GlobalId.php @@ -29,10 +29,12 @@ public function decode(string $globalID): array throw new GlobalIdException("Unexpectedly found more then 2 segments when decoding global id: {$globalID}."); } - /** @var array{ + /** + * @var array{ * 0: string, * 1: string, - * } $parts */ + * } $parts + */ return $parts; } diff --git a/src/Schema/Directives/BaseDirective.php b/src/Schema/Directives/BaseDirective.php index 9fc6e36a36..57d56812b1 100644 --- a/src/Schema/Directives/BaseDirective.php +++ b/src/Schema/Directives/BaseDirective.php @@ -223,19 +223,22 @@ protected function getMethodArgumentParts(string $argumentName): array throw new DefinitionException("Directive '{$this->name()}' must have an argument '{$argumentName}' in the form 'ClassName@methodName' or 'ClassName'"); } - /** @var array{ + /** + * @var array{ * 0: string, * 1?: string, - * } $argumentParts */ + * } $argumentParts + */ if (empty($argumentParts[1])) { $argumentParts[1] = '__invoke'; } - /** @var array{ + /** + * @var array{ * 0: string, * 1: string, - * } $argumentParts */ - + * } $argumentParts + */ return $argumentParts; } diff --git a/tests/Unit/Execution/Arguments/ArgPartitionerTest.php b/tests/Unit/Execution/Arguments/ArgPartitionerTest.php index 7c2f790f4a..6f41b6938c 100644 --- a/tests/Unit/Execution/Arguments/ArgPartitionerTest.php +++ b/tests/Unit/Execution/Arguments/ArgPartitionerTest.php @@ -25,7 +25,7 @@ public function testPartitionArgsWithArgResolvers(): void $nested->directives->push(new Nested()); $argumentSet->arguments['nested'] = $nested; - [$nestedArgs, $regularArgs] = ArgPartitioner::nestedArgResolvers($argumentSet, null); + [$nestedArgs, $regularArgs] = ArgPartitioner::postSaveArgResolvers($argumentSet, null); $this->assertSame( ['regular' => $regular], From 97c1d654c70971f60b6e725602a7ba11647c6256 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 29 Jun 2026 16:53:25 +0200 Subject: [PATCH 10/47] Fold directive-on-input-field tests into existing test classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- .../BelongsToDirectiveOnInputFieldTest.php | 405 ------------ .../MutationExecutor/BelongsToTest.php | 596 ++++++++++++++++++ .../HasOneDirectiveOnInputFieldTest.php | 208 ------ .../Execution/MutationExecutor/HasOneTest.php | 291 +++++++++ 4 files changed, 887 insertions(+), 613 deletions(-) delete mode 100644 tests/Integration/Execution/MutationExecutor/BelongsToDirectiveOnInputFieldTest.php delete mode 100644 tests/Integration/Execution/MutationExecutor/HasOneDirectiveOnInputFieldTest.php diff --git a/tests/Integration/Execution/MutationExecutor/BelongsToDirectiveOnInputFieldTest.php b/tests/Integration/Execution/MutationExecutor/BelongsToDirectiveOnInputFieldTest.php deleted file mode 100644 index cf8c4bb45a..0000000000 --- a/tests/Integration/Execution/MutationExecutor/BelongsToDirectiveOnInputFieldTest.php +++ /dev/null @@ -1,405 +0,0 @@ -create(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - createTask(input: { - name: "foo" - user: { - connect: 1 - } - }) { - id - name - user { - id - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'createTask' => [ - 'id' => '1', - 'name' => 'foo', - 'user' => [ - 'id' => '1', - ], - ], - ], - ]); - } - - public function testCreateWithNewBelongsTo(): void - { - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - createTask(input: { - name: "foo" - user: { - create: { - name: "New User" - } - } - }) { - id - name - user { - id - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'createTask' => [ - 'id' => '1', - 'name' => 'foo', - 'user' => [ - 'id' => '1', - ], - ], - ], - ]); - } - - public function testUpdateWithConnectBelongsTo(): void - { - $task = factory(Task::class)->create(); - $user = factory(User::class)->create(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - updateTask(input: { - id: 1 - name: "updated" - user: { - connect: 1 - } - }) { - id - name - user { - id - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'updateTask' => [ - 'id' => '1', - 'name' => 'updated', - 'user' => [ - 'id' => '1', - ], - ], - ], - ]); - } - - public function testUpdateWithCreateBelongsTo(): void - { - $task = factory(Task::class)->create(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - updateTask(input: { - id: 1 - name: "updated" - user: { - create: { - name: "New User" - } - } - }) { - id - name - user { - id - name - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'updateTask' => [ - 'id' => '1', - 'name' => 'updated', - 'user' => [ - 'id' => '1', - 'name' => 'New User', - ], - ], - ], - ]); - } - - public function testUpdateAndDisconnectBelongsTo(): void - { - $user = factory(User::class)->create(); - $task = factory(Task::class)->make(); - $task->user()->associate($user); - $task->save(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - updateTask(input: { - id: 1 - name: "updated" - user: { - disconnect: true - } - }) { - id - name - user { - id - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'updateTask' => [ - 'id' => '1', - 'name' => 'updated', - 'user' => null, - ], - ], - ]); - } - - public function testUpsertCreatesWithConnectBelongsTo(): void - { - $user = factory(User::class)->create(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - upsertTask(input: { - name: "foo" - user: { - connect: 1 - } - }) { - id - name - user { - id - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'upsertTask' => [ - 'id' => '1', - 'name' => 'foo', - 'user' => [ - 'id' => '1', - ], - ], - ], - ]); - } - - public function testUpsertUpdatesWithConnectBelongsTo(): void - { - $task = factory(Task::class)->create(); - $user = factory(User::class)->create(); - - $this->graphQL(/** @lang GraphQL */ <<id} - name: "updated" - user: { - connect: {$user->id} - } - }) { - id - name - user { - id - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'upsertTask' => [ - 'id' => "{$task->id}", - 'name' => 'updated', - 'user' => [ - 'id' => "{$user->id}", - ], - ], - ], - ]); - } - - public function testCreateWithNotNullForeignKeyConnect(): void - { - $task = factory(Task::class)->create(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - createPost(input: { - title: "My Post" - task: { - connect: 1 - } - }) { - id - title - task { - id - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'createPost' => [ - 'id' => '1', - 'title' => 'My Post', - 'task' => [ - 'id' => '1', - ], - ], - ], - ]); - } - - public function testCreateWithMismatchedFieldNameUsingRelationArg(): void - { - $task = factory(Task::class)->create(); - $user = factory(User::class)->create(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - createPost(input: { - title: "My Post" - task: { - connect: 1 - } - owner: { - connect: 1 - } - }) { - id - title - task { - id - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'createPost' => [ - 'id' => '1', - 'title' => 'My Post', - 'task' => [ - 'id' => '1', - ], - ], - ], - ]); - - $post = \Tests\Utils\Models\Post::findOrFail(1); - $this->assertSame($user->id, $post->user_id); - } -} diff --git a/tests/Integration/Execution/MutationExecutor/BelongsToTest.php b/tests/Integration/Execution/MutationExecutor/BelongsToTest.php index 660a9f4575..2888e22537 100644 --- a/tests/Integration/Execution/MutationExecutor/BelongsToTest.php +++ b/tests/Integration/Execution/MutationExecutor/BelongsToTest.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\DB; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; +use Tests\Utils\Models\Post; use Tests\Utils\Models\Role; use Tests\Utils\Models\Task; use Tests\Utils\Models\User; @@ -1312,4 +1313,599 @@ public function testCreateMultipleBelongsToThatDontExistYetWithExistingRecords() ], ]); } + + public function testCreateWithConnectBelongsToOnInputField(): void + { + $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' + type Task { + id: ID! + name: String! + user: User @belongsTo + } + + type User { + id: ID! + name: String! + } + + type Mutation { + createTask(input: CreateTaskInput! @spread): Task @create + } + + input CreateTaskInput { + name: String! + user: CreateUserRelation @belongsTo + } + + input CreateUserRelation { + connect: ID + create: CreateUserInput + } + + input CreateUserInput { + name: String! + } + GRAPHQL . self::PLACEHOLDER_QUERY; + + factory(User::class)->create(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createTask(input: { + name: "foo" + user: { + connect: 1 + } + }) { + id + name + user { + id + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createTask' => [ + 'id' => '1', + 'name' => 'foo', + 'user' => [ + 'id' => '1', + ], + ], + ], + ]); + } + + public function testCreateWithNewBelongsToOnInputField(): void + { + $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' + type Task { + id: ID! + name: String! + user: User @belongsTo + } + + type User { + id: ID! + name: String! + } + + type Mutation { + createTask(input: CreateTaskInput! @spread): Task @create + } + + input CreateTaskInput { + name: String! + user: CreateUserRelation @belongsTo + } + + input CreateUserRelation { + connect: ID + create: CreateUserInput + } + + input CreateUserInput { + name: String! + } + GRAPHQL . self::PLACEHOLDER_QUERY; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createTask(input: { + name: "foo" + user: { + create: { + name: "New User" + } + } + }) { + id + name + user { + id + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createTask' => [ + 'id' => '1', + 'name' => 'foo', + 'user' => [ + 'id' => '1', + ], + ], + ], + ]); + } + + public function testUpdateWithConnectBelongsToOnInputField(): void + { + $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' + type Task { + id: ID! + name: String! + user: User @belongsTo + } + + type User { + id: ID! + name: String! + } + + type Mutation { + updateTask(input: UpdateTaskInput! @spread): Task @update + } + + input UpdateTaskInput { + id: ID! + name: String + user: UpdateUserRelation @belongsTo + } + + input UpdateUserRelation { + connect: ID + create: CreateUserInput + disconnect: Boolean + } + + input CreateUserInput { + name: String! + } + GRAPHQL . self::PLACEHOLDER_QUERY; + + factory(Task::class)->create(); + factory(User::class)->create(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + updateTask(input: { + id: 1 + name: "updated" + user: { + connect: 1 + } + }) { + id + name + user { + id + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'updateTask' => [ + 'id' => '1', + 'name' => 'updated', + 'user' => [ + 'id' => '1', + ], + ], + ], + ]); + } + + public function testUpdateWithCreateBelongsToOnInputField(): void + { + $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' + type Task { + id: ID! + name: String! + user: User @belongsTo + } + + type User { + id: ID! + name: String! + } + + type Mutation { + updateTask(input: UpdateTaskInput! @spread): Task @update + } + + input UpdateTaskInput { + id: ID! + name: String + user: UpdateUserRelation @belongsTo + } + + input UpdateUserRelation { + connect: ID + create: CreateUserInput + disconnect: Boolean + } + + input CreateUserInput { + name: String! + } + GRAPHQL . self::PLACEHOLDER_QUERY; + + factory(Task::class)->create(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + updateTask(input: { + id: 1 + name: "updated" + user: { + create: { + name: "New User" + } + } + }) { + id + name + user { + id + name + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'updateTask' => [ + 'id' => '1', + 'name' => 'updated', + 'user' => [ + 'id' => '1', + 'name' => 'New User', + ], + ], + ], + ]); + } + + public function testUpdateAndDisconnectBelongsToOnInputField(): void + { + $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' + type Task { + id: ID! + name: String! + user: User @belongsTo + } + + type User { + id: ID! + name: String! + } + + type Mutation { + updateTask(input: UpdateTaskInput! @spread): Task @update + } + + input UpdateTaskInput { + id: ID! + name: String + user: UpdateUserRelation @belongsTo + } + + input UpdateUserRelation { + connect: ID + create: CreateUserInput + disconnect: Boolean + } + + input CreateUserInput { + name: String! + } + GRAPHQL . self::PLACEHOLDER_QUERY; + + $user = factory(User::class)->create(); + $task = factory(Task::class)->make(); + $task->user()->associate($user); + $task->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + updateTask(input: { + id: 1 + name: "updated" + user: { + disconnect: true + } + }) { + id + name + user { + id + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'updateTask' => [ + 'id' => '1', + 'name' => 'updated', + 'user' => null, + ], + ], + ]); + } + + public function testUpsertCreatesWithConnectBelongsToOnInputField(): void + { + $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' + type Task { + id: ID! + name: String! + user: User @belongsTo + } + + type User { + id: ID! + name: String! + } + + type Mutation { + upsertTask(input: UpsertTaskInput! @spread): Task @upsert + } + + input UpsertTaskInput { + id: ID + name: String! + user: UpsertUserRelation @belongsTo + } + + input UpsertUserRelation { + connect: ID + create: CreateUserInput + disconnect: Boolean + } + + input CreateUserInput { + name: String! + } + GRAPHQL . self::PLACEHOLDER_QUERY; + + factory(User::class)->create(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + upsertTask(input: { + name: "foo" + user: { + connect: 1 + } + }) { + id + name + user { + id + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'upsertTask' => [ + 'id' => '1', + 'name' => 'foo', + 'user' => [ + 'id' => '1', + ], + ], + ], + ]); + } + + public function testUpsertUpdatesWithConnectBelongsToOnInputField(): void + { + $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' + type Task { + id: ID! + name: String! + user: User @belongsTo + } + + type User { + id: ID! + name: String! + } + + type Mutation { + upsertTask(input: UpsertTaskInput! @spread): Task @upsert + } + + input UpsertTaskInput { + id: ID + name: String! + user: UpsertUserRelation @belongsTo + } + + input UpsertUserRelation { + connect: ID + create: CreateUserInput + disconnect: Boolean + } + + input CreateUserInput { + name: String! + } + GRAPHQL . self::PLACEHOLDER_QUERY; + + $task = factory(Task::class)->create(); + $user = factory(User::class)->create(); + + $this->graphQL(/** @lang GraphQL */ <<id} + name: "updated" + user: { + connect: {$user->id} + } + }) { + id + name + user { + id + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'upsertTask' => [ + 'id' => "{$task->id}", + 'name' => 'updated', + 'user' => [ + 'id' => "{$user->id}", + ], + ], + ], + ]); + } + + public function testCreateWithNotNullForeignKeyConnectOnInputField(): void + { + $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' + type Post { + id: ID! + title: String! + task: Task @belongsTo + } + + type Task { + id: ID! + name: String! + } + + type Mutation { + createPost(input: CreatePostInput! @spread): Post @create + } + + input CreatePostInput { + title: String! + task: CreateTaskRelation @belongsTo + } + + input CreateTaskRelation { + connect: ID + } + GRAPHQL . self::PLACEHOLDER_QUERY; + + factory(Task::class)->create(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createPost(input: { + title: "My Post" + task: { + connect: 1 + } + }) { + id + title + task { + id + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createPost' => [ + 'id' => '1', + 'title' => 'My Post', + 'task' => [ + 'id' => '1', + ], + ], + ], + ]); + } + + public function testCreateWithMismatchedFieldNameUsingRelationArgOnInputField(): void + { + $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' + type Post { + id: ID! + title: String! + task: Task @belongsTo + } + + type Task { + id: ID! + name: String! + } + + type User { + id: ID! + name: String! + } + + type Mutation { + createPost(input: CreatePostInput! @spread): Post @create + } + + input CreatePostInput { + title: String! + task: CreateTaskRelation @belongsTo + owner: CreateUserRelation @belongsTo(relation: "user") + } + + input CreateTaskRelation { + connect: ID + } + + input CreateUserRelation { + connect: ID + } + GRAPHQL . self::PLACEHOLDER_QUERY; + + factory(Task::class)->create(); + $user = factory(User::class)->create(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createPost(input: { + title: "My Post" + task: { + connect: 1 + } + owner: { + connect: 1 + } + }) { + id + title + task { + id + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createPost' => [ + 'id' => '1', + 'title' => 'My Post', + 'task' => [ + 'id' => '1', + ], + ], + ], + ]); + + $post = Post::findOrFail(1); + $this->assertSame($user->id, $post->user_id); + } } diff --git a/tests/Integration/Execution/MutationExecutor/HasOneDirectiveOnInputFieldTest.php b/tests/Integration/Execution/MutationExecutor/HasOneDirectiveOnInputFieldTest.php deleted file mode 100644 index c4d9aedbd3..0000000000 --- a/tests/Integration/Execution/MutationExecutor/HasOneDirectiveOnInputFieldTest.php +++ /dev/null @@ -1,208 +0,0 @@ -graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - createTask(input: { - name: "foo" - post: { - create: { - title: "bar" - } - } - }) { - id - name - post { - id - title - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'createTask' => [ - 'id' => '1', - 'name' => 'foo', - 'post' => [ - 'id' => '1', - 'title' => 'bar', - ], - ], - ], - ]); - } - - public function testUpdateWithCreateHasOne(): void - { - $task = factory(Task::class)->create(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - updateTask(input: { - id: 1 - name: "updated" - post: { - create: { - title: "new post" - } - } - }) { - id - name - post { - id - title - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'updateTask' => [ - 'id' => '1', - 'name' => 'updated', - 'post' => [ - 'id' => '1', - 'title' => 'new post', - ], - ], - ], - ]); - } - - public function testUpdateWithUpdateHasOne(): void - { - $task = factory(Task::class)->create(); - $post = factory(Post::class)->make(); - $post->title = 'original'; - $post->task()->associate($task); - $post->save(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - updateTask(input: { - id: 1 - post: { - update: { - id: 1 - title: "changed" - } - } - }) { - id - post { - id - title - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'updateTask' => [ - 'id' => '1', - 'post' => [ - 'id' => '1', - 'title' => 'changed', - ], - ], - ], - ]); - } - - public function testUpdateWithDeleteHasOne(): void - { - $task = factory(Task::class)->create(); - $post = factory(Post::class)->make(); - $post->task()->associate($task); - $post->save(); - - $this->graphQL(/** @lang GraphQL */ <<id} - } - }) { - id - post { - id - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'updateTask' => [ - 'id' => '1', - 'post' => null, - ], - ], - ]); - - $this->assertNull(Post::find($post->id)); - } -} diff --git a/tests/Integration/Execution/MutationExecutor/HasOneTest.php b/tests/Integration/Execution/MutationExecutor/HasOneTest.php index b6856e22c7..79acb5fd9f 100644 --- a/tests/Integration/Execution/MutationExecutor/HasOneTest.php +++ b/tests/Integration/Execution/MutationExecutor/HasOneTest.php @@ -546,4 +546,295 @@ public function testUpdateAndDeleteHasOne(string $action): void ], ]); } + + public function testCreateWithNewHasOneOnInputField(): void + { + $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' + type Task { + id: ID! + name: String! + post: Post @hasOne + } + + type Post { + id: ID! + title: String! + body: String! + } + + type Mutation { + createTask(input: CreateTaskInput! @spread): Task @create + } + + input CreateTaskInput { + name: String! + post: CreatePostRelation @hasOne + } + + input CreatePostRelation { + create: CreatePostInput + } + + input CreatePostInput { + title: String! + } + GRAPHQL . self::PLACEHOLDER_QUERY; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createTask(input: { + name: "foo" + post: { + create: { + title: "bar" + } + } + }) { + id + name + post { + id + title + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createTask' => [ + 'id' => '1', + 'name' => 'foo', + 'post' => [ + 'id' => '1', + 'title' => 'bar', + ], + ], + ], + ]); + } + + public function testUpdateWithCreateHasOneOnInputField(): void + { + $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' + type Task { + id: ID! + name: String! + post: Post @hasOne + } + + type Post { + id: ID! + title: String! + body: String! + } + + type Mutation { + updateTask(input: UpdateTaskInput! @spread): Task @update + } + + input UpdateTaskInput { + id: ID! + name: String + post: UpdatePostRelation @hasOne + } + + input UpdatePostRelation { + create: CreatePostInput + update: UpdatePostInput + delete: ID + } + + input CreatePostInput { + title: String! + } + + input UpdatePostInput { + id: ID! + title: String + } + GRAPHQL . self::PLACEHOLDER_QUERY; + + factory(Task::class)->create(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + updateTask(input: { + id: 1 + name: "updated" + post: { + create: { + title: "new post" + } + } + }) { + id + name + post { + id + title + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'updateTask' => [ + 'id' => '1', + 'name' => 'updated', + 'post' => [ + 'id' => '1', + 'title' => 'new post', + ], + ], + ], + ]); + } + + public function testUpdateWithUpdateHasOneOnInputField(): void + { + $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' + type Task { + id: ID! + name: String! + post: Post @hasOne + } + + type Post { + id: ID! + title: String! + body: String! + } + + type Mutation { + updateTask(input: UpdateTaskInput! @spread): Task @update + } + + input UpdateTaskInput { + id: ID! + name: String + post: UpdatePostRelation @hasOne + } + + input UpdatePostRelation { + create: CreatePostInput + update: UpdatePostInput + delete: ID + } + + input CreatePostInput { + title: String! + } + + input UpdatePostInput { + id: ID! + title: String + } + GRAPHQL . self::PLACEHOLDER_QUERY; + + $task = factory(Task::class)->create(); + $post = factory(Post::class)->make(); + $post->title = 'original'; + $post->task()->associate($task); + $post->save(); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + updateTask(input: { + id: 1 + post: { + update: { + id: 1 + title: "changed" + } + } + }) { + id + post { + id + title + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'updateTask' => [ + 'id' => '1', + 'post' => [ + 'id' => '1', + 'title' => 'changed', + ], + ], + ], + ]); + } + + public function testUpdateWithDeleteHasOneOnInputField(): void + { + $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' + type Task { + id: ID! + name: String! + post: Post @hasOne + } + + type Post { + id: ID! + title: String! + body: String! + } + + type Mutation { + updateTask(input: UpdateTaskInput! @spread): Task @update + } + + input UpdateTaskInput { + id: ID! + name: String + post: UpdatePostRelation @hasOne + } + + input UpdatePostRelation { + create: CreatePostInput + update: UpdatePostInput + delete: ID + } + + input CreatePostInput { + title: String! + } + + input UpdatePostInput { + id: ID! + title: String + } + GRAPHQL . self::PLACEHOLDER_QUERY; + + $task = factory(Task::class)->create(); + $post = factory(Post::class)->make(); + $post->task()->associate($task); + $post->save(); + + $this->graphQL(/** @lang GraphQL */ <<id} + } + }) { + id + post { + id + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'updateTask' => [ + 'id' => '1', + 'post' => null, + ], + ], + ]); + + $this->assertNull(Post::find($post->id)); + } } From 9c0e9299b8d4396832590ea502224cc1fdc996ae Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 29 Jun 2026 17:40:23 +0200 Subject: [PATCH 11/47] Remove @belongsTo/@hasOne on INPUT_FIELD_DEFINITION, keep PreSaveArgResolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert directive changes and their ~890 lines of tests — that use case isn't needed. The PreSaveArgResolver interface and its integration into ArgPartitioner/SaveModel remain as the extension point for custom directives. Add unit + integration tests proving PreSaveArgResolver works with a custom @uppercase directive. 🤖 Generated with Claude Code --- CHANGELOG.md | 2 +- src/Schema/Directives/BelongsToDirective.php | 24 +- src/Schema/Directives/HasOneDirective.php | 20 +- .../MutationExecutor/BelongsToTest.php | 596 ------------------ .../Execution/MutationExecutor/HasOneTest.php | 291 --------- .../Schema/Directives/CreateDirectiveTest.php | 36 ++ .../Arguments/ArgPartitionerTest.php | 36 ++ .../Arguments/Fixtures/PreNested.php | 18 + tests/Utils/Directives/UppercaseDirective.php | 23 + 9 files changed, 118 insertions(+), 928 deletions(-) create mode 100644 tests/Unit/Execution/Arguments/Fixtures/PreNested.php create mode 100644 tests/Utils/Directives/UppercaseDirective.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 04123897c6..b31283eec0 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 -- Support `@belongsTo` and `@hasOne` on `INPUT_FIELD_DEFINITION` for explicit nested mutation handling https://github.com/nuwave/lighthouse/pull/2777 +- Add `PreSaveArgResolver` interface for custom directives that resolve before `$model->save()` https://github.com/nuwave/lighthouse/pull/2777 ## v6.67.0 diff --git a/src/Schema/Directives/BelongsToDirective.php b/src/Schema/Directives/BelongsToDirective.php index 4d244ccb53..0b0a61ca3b 100644 --- a/src/Schema/Directives/BelongsToDirective.php +++ b/src/Schema/Directives/BelongsToDirective.php @@ -2,21 +2,13 @@ namespace Nuwave\Lighthouse\Schema\Directives; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Relations\MorphTo; -use Nuwave\Lighthouse\Execution\Arguments\NestedBelongsTo; -use Nuwave\Lighthouse\Execution\Arguments\ResolveNested; -use Nuwave\Lighthouse\Support\Contracts\PreSaveArgResolver; - -class BelongsToDirective extends RelationDirective implements PreSaveArgResolver +class BelongsToDirective extends RelationDirective { public static function definition(): string { return /** @lang GraphQL */ <<<'GRAPHQL' """ Resolves a field through the Eloquent `BelongsTo` relationship. -When used on an input field, handles nested mutations for the BelongsTo relationship. """ directive @belongsTo( """ @@ -29,19 +21,7 @@ public static function definition(): string Apply scopes to the underlying query. """ scopes: [String!] -) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION +) on FIELD_DEFINITION GRAPHQL; } - - public function __invoke(mixed $root, mixed $value): void - { - assert($root instanceof Model, 'BelongsToDirective is only used as an ArgResolver on Eloquent models.'); - $relationName = $this->directiveArgValue('relation') ?? $this->nodeName(); - $relation = $root->{$relationName}(); - assert( - $relation instanceof BelongsTo && ! $relation instanceof MorphTo, - "Use @morphTo for MorphTo relations, @belongsTo does not support them: {$relationName}.", - ); - (new ResolveNested(new NestedBelongsTo($relation)))($root, $value); - } } diff --git a/src/Schema/Directives/HasOneDirective.php b/src/Schema/Directives/HasOneDirective.php index 33f26865b8..2daede7831 100644 --- a/src/Schema/Directives/HasOneDirective.php +++ b/src/Schema/Directives/HasOneDirective.php @@ -2,20 +2,13 @@ namespace Nuwave\Lighthouse\Schema\Directives; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\HasOne; -use Nuwave\Lighthouse\Execution\Arguments\NestedOneToOne; -use Nuwave\Lighthouse\Execution\Arguments\ResolveNested; -use Nuwave\Lighthouse\Support\Contracts\ArgResolver; - -class HasOneDirective extends RelationDirective implements ArgResolver +class HasOneDirective extends RelationDirective { public static function definition(): string { return /** @lang GraphQL */ <<<'GRAPHQL' """ Corresponds to [the Eloquent relationship HasOne](https://laravel.com/docs/eloquent-relationships#one-to-one). -When used on an input field, handles nested mutations for the HasOne relationship. """ directive @hasOne( """ @@ -28,16 +21,7 @@ public static function definition(): string Apply scopes to the underlying query. """ scopes: [String!] -) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION +) on FIELD_DEFINITION GRAPHQL; } - - public function __invoke(mixed $root, mixed $value): void - { - assert($root instanceof Model, 'HasOneDirective is only used as an ArgResolver on Eloquent models.'); - $relationName = $this->directiveArgValue('relation') ?? $this->nodeName(); - $relation = $root->{$relationName}(); - assert($relation instanceof HasOne, "Use @hasOne only for HasOne relations, not for: {$relationName}."); - (new ResolveNested(new NestedOneToOne($relationName)))($root, $value); - } } diff --git a/tests/Integration/Execution/MutationExecutor/BelongsToTest.php b/tests/Integration/Execution/MutationExecutor/BelongsToTest.php index 2888e22537..660a9f4575 100644 --- a/tests/Integration/Execution/MutationExecutor/BelongsToTest.php +++ b/tests/Integration/Execution/MutationExecutor/BelongsToTest.php @@ -6,7 +6,6 @@ use Illuminate\Support\Facades\DB; use PHPUnit\Framework\Attributes\DataProvider; use Tests\DBTestCase; -use Tests\Utils\Models\Post; use Tests\Utils\Models\Role; use Tests\Utils\Models\Task; use Tests\Utils\Models\User; @@ -1313,599 +1312,4 @@ public function testCreateMultipleBelongsToThatDontExistYetWithExistingRecords() ], ]); } - - public function testCreateWithConnectBelongsToOnInputField(): void - { - $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' - type Task { - id: ID! - name: String! - user: User @belongsTo - } - - type User { - id: ID! - name: String! - } - - type Mutation { - createTask(input: CreateTaskInput! @spread): Task @create - } - - input CreateTaskInput { - name: String! - user: CreateUserRelation @belongsTo - } - - input CreateUserRelation { - connect: ID - create: CreateUserInput - } - - input CreateUserInput { - name: String! - } - GRAPHQL . self::PLACEHOLDER_QUERY; - - factory(User::class)->create(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - createTask(input: { - name: "foo" - user: { - connect: 1 - } - }) { - id - name - user { - id - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'createTask' => [ - 'id' => '1', - 'name' => 'foo', - 'user' => [ - 'id' => '1', - ], - ], - ], - ]); - } - - public function testCreateWithNewBelongsToOnInputField(): void - { - $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' - type Task { - id: ID! - name: String! - user: User @belongsTo - } - - type User { - id: ID! - name: String! - } - - type Mutation { - createTask(input: CreateTaskInput! @spread): Task @create - } - - input CreateTaskInput { - name: String! - user: CreateUserRelation @belongsTo - } - - input CreateUserRelation { - connect: ID - create: CreateUserInput - } - - input CreateUserInput { - name: String! - } - GRAPHQL . self::PLACEHOLDER_QUERY; - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - createTask(input: { - name: "foo" - user: { - create: { - name: "New User" - } - } - }) { - id - name - user { - id - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'createTask' => [ - 'id' => '1', - 'name' => 'foo', - 'user' => [ - 'id' => '1', - ], - ], - ], - ]); - } - - public function testUpdateWithConnectBelongsToOnInputField(): void - { - $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' - type Task { - id: ID! - name: String! - user: User @belongsTo - } - - type User { - id: ID! - name: String! - } - - type Mutation { - updateTask(input: UpdateTaskInput! @spread): Task @update - } - - input UpdateTaskInput { - id: ID! - name: String - user: UpdateUserRelation @belongsTo - } - - input UpdateUserRelation { - connect: ID - create: CreateUserInput - disconnect: Boolean - } - - input CreateUserInput { - name: String! - } - GRAPHQL . self::PLACEHOLDER_QUERY; - - factory(Task::class)->create(); - factory(User::class)->create(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - updateTask(input: { - id: 1 - name: "updated" - user: { - connect: 1 - } - }) { - id - name - user { - id - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'updateTask' => [ - 'id' => '1', - 'name' => 'updated', - 'user' => [ - 'id' => '1', - ], - ], - ], - ]); - } - - public function testUpdateWithCreateBelongsToOnInputField(): void - { - $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' - type Task { - id: ID! - name: String! - user: User @belongsTo - } - - type User { - id: ID! - name: String! - } - - type Mutation { - updateTask(input: UpdateTaskInput! @spread): Task @update - } - - input UpdateTaskInput { - id: ID! - name: String - user: UpdateUserRelation @belongsTo - } - - input UpdateUserRelation { - connect: ID - create: CreateUserInput - disconnect: Boolean - } - - input CreateUserInput { - name: String! - } - GRAPHQL . self::PLACEHOLDER_QUERY; - - factory(Task::class)->create(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - updateTask(input: { - id: 1 - name: "updated" - user: { - create: { - name: "New User" - } - } - }) { - id - name - user { - id - name - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'updateTask' => [ - 'id' => '1', - 'name' => 'updated', - 'user' => [ - 'id' => '1', - 'name' => 'New User', - ], - ], - ], - ]); - } - - public function testUpdateAndDisconnectBelongsToOnInputField(): void - { - $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' - type Task { - id: ID! - name: String! - user: User @belongsTo - } - - type User { - id: ID! - name: String! - } - - type Mutation { - updateTask(input: UpdateTaskInput! @spread): Task @update - } - - input UpdateTaskInput { - id: ID! - name: String - user: UpdateUserRelation @belongsTo - } - - input UpdateUserRelation { - connect: ID - create: CreateUserInput - disconnect: Boolean - } - - input CreateUserInput { - name: String! - } - GRAPHQL . self::PLACEHOLDER_QUERY; - - $user = factory(User::class)->create(); - $task = factory(Task::class)->make(); - $task->user()->associate($user); - $task->save(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - updateTask(input: { - id: 1 - name: "updated" - user: { - disconnect: true - } - }) { - id - name - user { - id - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'updateTask' => [ - 'id' => '1', - 'name' => 'updated', - 'user' => null, - ], - ], - ]); - } - - public function testUpsertCreatesWithConnectBelongsToOnInputField(): void - { - $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' - type Task { - id: ID! - name: String! - user: User @belongsTo - } - - type User { - id: ID! - name: String! - } - - type Mutation { - upsertTask(input: UpsertTaskInput! @spread): Task @upsert - } - - input UpsertTaskInput { - id: ID - name: String! - user: UpsertUserRelation @belongsTo - } - - input UpsertUserRelation { - connect: ID - create: CreateUserInput - disconnect: Boolean - } - - input CreateUserInput { - name: String! - } - GRAPHQL . self::PLACEHOLDER_QUERY; - - factory(User::class)->create(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - upsertTask(input: { - name: "foo" - user: { - connect: 1 - } - }) { - id - name - user { - id - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'upsertTask' => [ - 'id' => '1', - 'name' => 'foo', - 'user' => [ - 'id' => '1', - ], - ], - ], - ]); - } - - public function testUpsertUpdatesWithConnectBelongsToOnInputField(): void - { - $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' - type Task { - id: ID! - name: String! - user: User @belongsTo - } - - type User { - id: ID! - name: String! - } - - type Mutation { - upsertTask(input: UpsertTaskInput! @spread): Task @upsert - } - - input UpsertTaskInput { - id: ID - name: String! - user: UpsertUserRelation @belongsTo - } - - input UpsertUserRelation { - connect: ID - create: CreateUserInput - disconnect: Boolean - } - - input CreateUserInput { - name: String! - } - GRAPHQL . self::PLACEHOLDER_QUERY; - - $task = factory(Task::class)->create(); - $user = factory(User::class)->create(); - - $this->graphQL(/** @lang GraphQL */ <<id} - name: "updated" - user: { - connect: {$user->id} - } - }) { - id - name - user { - id - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'upsertTask' => [ - 'id' => "{$task->id}", - 'name' => 'updated', - 'user' => [ - 'id' => "{$user->id}", - ], - ], - ], - ]); - } - - public function testCreateWithNotNullForeignKeyConnectOnInputField(): void - { - $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' - type Post { - id: ID! - title: String! - task: Task @belongsTo - } - - type Task { - id: ID! - name: String! - } - - type Mutation { - createPost(input: CreatePostInput! @spread): Post @create - } - - input CreatePostInput { - title: String! - task: CreateTaskRelation @belongsTo - } - - input CreateTaskRelation { - connect: ID - } - GRAPHQL . self::PLACEHOLDER_QUERY; - - factory(Task::class)->create(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - createPost(input: { - title: "My Post" - task: { - connect: 1 - } - }) { - id - title - task { - id - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'createPost' => [ - 'id' => '1', - 'title' => 'My Post', - 'task' => [ - 'id' => '1', - ], - ], - ], - ]); - } - - public function testCreateWithMismatchedFieldNameUsingRelationArgOnInputField(): void - { - $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' - type Post { - id: ID! - title: String! - task: Task @belongsTo - } - - type Task { - id: ID! - name: String! - } - - type User { - id: ID! - name: String! - } - - type Mutation { - createPost(input: CreatePostInput! @spread): Post @create - } - - input CreatePostInput { - title: String! - task: CreateTaskRelation @belongsTo - owner: CreateUserRelation @belongsTo(relation: "user") - } - - input CreateTaskRelation { - connect: ID - } - - input CreateUserRelation { - connect: ID - } - GRAPHQL . self::PLACEHOLDER_QUERY; - - factory(Task::class)->create(); - $user = factory(User::class)->create(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - createPost(input: { - title: "My Post" - task: { - connect: 1 - } - owner: { - connect: 1 - } - }) { - id - title - task { - id - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'createPost' => [ - 'id' => '1', - 'title' => 'My Post', - 'task' => [ - 'id' => '1', - ], - ], - ], - ]); - - $post = Post::findOrFail(1); - $this->assertSame($user->id, $post->user_id); - } } diff --git a/tests/Integration/Execution/MutationExecutor/HasOneTest.php b/tests/Integration/Execution/MutationExecutor/HasOneTest.php index 79acb5fd9f..b6856e22c7 100644 --- a/tests/Integration/Execution/MutationExecutor/HasOneTest.php +++ b/tests/Integration/Execution/MutationExecutor/HasOneTest.php @@ -546,295 +546,4 @@ public function testUpdateAndDeleteHasOne(string $action): void ], ]); } - - public function testCreateWithNewHasOneOnInputField(): void - { - $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' - type Task { - id: ID! - name: String! - post: Post @hasOne - } - - type Post { - id: ID! - title: String! - body: String! - } - - type Mutation { - createTask(input: CreateTaskInput! @spread): Task @create - } - - input CreateTaskInput { - name: String! - post: CreatePostRelation @hasOne - } - - input CreatePostRelation { - create: CreatePostInput - } - - input CreatePostInput { - title: String! - } - GRAPHQL . self::PLACEHOLDER_QUERY; - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - createTask(input: { - name: "foo" - post: { - create: { - title: "bar" - } - } - }) { - id - name - post { - id - title - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'createTask' => [ - 'id' => '1', - 'name' => 'foo', - 'post' => [ - 'id' => '1', - 'title' => 'bar', - ], - ], - ], - ]); - } - - public function testUpdateWithCreateHasOneOnInputField(): void - { - $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' - type Task { - id: ID! - name: String! - post: Post @hasOne - } - - type Post { - id: ID! - title: String! - body: String! - } - - type Mutation { - updateTask(input: UpdateTaskInput! @spread): Task @update - } - - input UpdateTaskInput { - id: ID! - name: String - post: UpdatePostRelation @hasOne - } - - input UpdatePostRelation { - create: CreatePostInput - update: UpdatePostInput - delete: ID - } - - input CreatePostInput { - title: String! - } - - input UpdatePostInput { - id: ID! - title: String - } - GRAPHQL . self::PLACEHOLDER_QUERY; - - factory(Task::class)->create(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - updateTask(input: { - id: 1 - name: "updated" - post: { - create: { - title: "new post" - } - } - }) { - id - name - post { - id - title - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'updateTask' => [ - 'id' => '1', - 'name' => 'updated', - 'post' => [ - 'id' => '1', - 'title' => 'new post', - ], - ], - ], - ]); - } - - public function testUpdateWithUpdateHasOneOnInputField(): void - { - $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' - type Task { - id: ID! - name: String! - post: Post @hasOne - } - - type Post { - id: ID! - title: String! - body: String! - } - - type Mutation { - updateTask(input: UpdateTaskInput! @spread): Task @update - } - - input UpdateTaskInput { - id: ID! - name: String - post: UpdatePostRelation @hasOne - } - - input UpdatePostRelation { - create: CreatePostInput - update: UpdatePostInput - delete: ID - } - - input CreatePostInput { - title: String! - } - - input UpdatePostInput { - id: ID! - title: String - } - GRAPHQL . self::PLACEHOLDER_QUERY; - - $task = factory(Task::class)->create(); - $post = factory(Post::class)->make(); - $post->title = 'original'; - $post->task()->associate($task); - $post->save(); - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - updateTask(input: { - id: 1 - post: { - update: { - id: 1 - title: "changed" - } - } - }) { - id - post { - id - title - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'updateTask' => [ - 'id' => '1', - 'post' => [ - 'id' => '1', - 'title' => 'changed', - ], - ], - ], - ]); - } - - public function testUpdateWithDeleteHasOneOnInputField(): void - { - $this->schema = /** @lang GraphQL */ <<<'GRAPHQL' - type Task { - id: ID! - name: String! - post: Post @hasOne - } - - type Post { - id: ID! - title: String! - body: String! - } - - type Mutation { - updateTask(input: UpdateTaskInput! @spread): Task @update - } - - input UpdateTaskInput { - id: ID! - name: String - post: UpdatePostRelation @hasOne - } - - input UpdatePostRelation { - create: CreatePostInput - update: UpdatePostInput - delete: ID - } - - input CreatePostInput { - title: String! - } - - input UpdatePostInput { - id: ID! - title: String - } - GRAPHQL . self::PLACEHOLDER_QUERY; - - $task = factory(Task::class)->create(); - $post = factory(Post::class)->make(); - $post->task()->associate($task); - $post->save(); - - $this->graphQL(/** @lang GraphQL */ <<id} - } - }) { - id - post { - id - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'updateTask' => [ - 'id' => '1', - 'post' => null, - ], - ], - ]); - - $this->assertNull(Post::find($post->id)); - } } diff --git a/tests/Integration/Schema/Directives/CreateDirectiveTest.php b/tests/Integration/Schema/Directives/CreateDirectiveTest.php index e50936dfc6..033a798858 100644 --- a/tests/Integration/Schema/Directives/CreateDirectiveTest.php +++ b/tests/Integration/Schema/Directives/CreateDirectiveTest.php @@ -584,4 +584,40 @@ public function testTurnOnMassAssignment(): void } GRAPHQL); } + + public function testPreSaveArgResolverIsCalledBeforeSave(): void + { + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Company { + id: ID! + name: String! + } + + type Mutation { + createCompany(input: CreateCompanyInput! @spread): Company @create + } + + input CreateCompanyInput { + name: String! @uppercase + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createCompany(input: { + name: "foo" + }) { + id + name + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createCompany' => [ + 'id' => '1', + 'name' => 'FOO', + ], + ], + ]); + } } diff --git a/tests/Unit/Execution/Arguments/ArgPartitionerTest.php b/tests/Unit/Execution/Arguments/ArgPartitionerTest.php index 6f41b6938c..793c302b9d 100644 --- a/tests/Unit/Execution/Arguments/ArgPartitionerTest.php +++ b/tests/Unit/Execution/Arguments/ArgPartitionerTest.php @@ -9,6 +9,7 @@ use Nuwave\Lighthouse\Execution\Arguments\ArgumentSet; use Tests\TestCase; use Tests\Unit\Execution\Arguments\Fixtures\Nested; +use Tests\Unit\Execution\Arguments\Fixtures\PreNested; use Tests\Utils\Models\User; use Tests\Utils\Models\WithoutRelationClassImport; @@ -38,6 +39,41 @@ public function testPartitionArgsWithArgResolvers(): void ); } + public function testPartitionPreSaveArgResolvers(): void + { + $argumentSet = new ArgumentSet(); + + $regular = new Argument(); + $argumentSet->arguments['regular'] = $regular; + + $postSave = new Argument(); + $postSave->directives->push(new Nested()); + $argumentSet->arguments['postSave'] = $postSave; + + $preSave = new Argument(); + $preSave->directives->push(new PreNested()); + $argumentSet->arguments['preSave'] = $preSave; + + [$postSaveArgs, $regularArgs] = ArgPartitioner::postSaveArgResolvers($argumentSet, null); + + $this->assertSame( + ['postSave' => $postSave], + $postSaveArgs->arguments, + ); + + [$preSaveArgs, $rest] = ArgPartitioner::preSaveArgResolvers($regularArgs); + + $this->assertSame( + ['preSave' => $preSave], + $preSaveArgs->arguments, + ); + + $this->assertSame( + ['regular' => $regular], + $rest->arguments, + ); + } + public function testPartitionArgsThatMatchRelationMethods(): void { $argumentSet = new ArgumentSet(); diff --git a/tests/Unit/Execution/Arguments/Fixtures/PreNested.php b/tests/Unit/Execution/Arguments/Fixtures/PreNested.php new file mode 100644 index 0000000000..f4cb799fa7 --- /dev/null +++ b/tests/Unit/Execution/Arguments/Fixtures/PreNested.php @@ -0,0 +1,18 @@ +setAttribute($this->nodeName(), strtoupper($args)); + } +} From a8b13aa946d8ad3bf4e86397c68457eb9674e9c0 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 29 Jun 2026 17:46:18 +0200 Subject: [PATCH 12/47] Replace @uppercase with @connectRelated test directive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proves the real use case: setting a FK via BelongsTo before save. Uses `relation:` arg to avoid collision with implicit relation detection. 🤖 Generated with Claude Code --- .../Schema/Directives/CreateDirectiveTest.php | 35 +++++++++++++------ .../Directives/ConnectRelatedDirective.php | 29 +++++++++++++++ tests/Utils/Directives/UppercaseDirective.php | 23 ------------ 3 files changed, 54 insertions(+), 33 deletions(-) create mode 100644 tests/Utils/Directives/ConnectRelatedDirective.php delete mode 100644 tests/Utils/Directives/UppercaseDirective.php diff --git a/tests/Integration/Schema/Directives/CreateDirectiveTest.php b/tests/Integration/Schema/Directives/CreateDirectiveTest.php index 033a798858..08bd7567c4 100644 --- a/tests/Integration/Schema/Directives/CreateDirectiveTest.php +++ b/tests/Integration/Schema/Directives/CreateDirectiveTest.php @@ -585,37 +585,52 @@ public function testTurnOnMassAssignment(): void GRAPHQL); } - public function testPreSaveArgResolverIsCalledBeforeSave(): void + public function testPreSaveArgResolverSetsForeignKeyBeforeSave(): void { + $user = factory(User::class)->create(); + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' - type Company { + type Task { id: ID! name: String! + user: User @belongsTo + } + + type User { + id: ID! } type Mutation { - createCompany(input: CreateCompanyInput! @spread): Company @create + createTask(input: CreateTaskInput! @spread): Task @create } - input CreateCompanyInput { - name: String! @uppercase + input CreateTaskInput { + name: String! + owner: ID @connectRelated(relation: "user") } GRAPHQL; - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + $this->graphQL(/** @lang GraphQL */ <<id}" }) { id name + user { + id + } } } GRAPHQL)->assertJson([ 'data' => [ - 'createCompany' => [ + 'createTask' => [ 'id' => '1', - 'name' => 'FOO', + 'name' => 'My task', + 'user' => [ + 'id' => "{$user->id}", + ], ], ], ]); diff --git a/tests/Utils/Directives/ConnectRelatedDirective.php b/tests/Utils/Directives/ConnectRelatedDirective.php new file mode 100644 index 0000000000..1a95d1f006 --- /dev/null +++ b/tests/Utils/Directives/ConnectRelatedDirective.php @@ -0,0 +1,29 @@ +directiveArgValue('relation') + ?? $this->nodeName(); + $relation = $parent->{$relationName}(); + assert($relation instanceof BelongsTo); + + $relation->associate($id); + } +} diff --git a/tests/Utils/Directives/UppercaseDirective.php b/tests/Utils/Directives/UppercaseDirective.php deleted file mode 100644 index a624f00bb8..0000000000 --- a/tests/Utils/Directives/UppercaseDirective.php +++ /dev/null @@ -1,23 +0,0 @@ -setAttribute($this->nodeName(), strtoupper($args)); - } -} From 12e220fa554416e190461dca947f2497c1905ae8 Mon Sep 17 00:00:00 2001 From: spawnia Date: Mon, 29 Jun 2026 15:49:53 +0000 Subject: [PATCH 13/47] Apply php-cs-fixer changes --- tests/Utils/Directives/ConnectRelatedDirective.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Utils/Directives/ConnectRelatedDirective.php b/tests/Utils/Directives/ConnectRelatedDirective.php index 1a95d1f006..d7aca591d6 100644 --- a/tests/Utils/Directives/ConnectRelatedDirective.php +++ b/tests/Utils/Directives/ConnectRelatedDirective.php @@ -16,7 +16,7 @@ public static function definition(): string GRAPHQL; } - /** @param Model $parent */ + /** @param Model $parent */ public function __invoke($parent, $id): void { $relationName = $this->directiveArgValue('relation') From 50d49a1c9c187c447bac8ed26c9202aff0e367cb Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 29 Jun 2026 18:09:51 +0200 Subject: [PATCH 14/47] Pass null through to PreSaveArgResolver implementations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the null guard so directives can handle disassociation (e.g. $relation->associate(null)). Adds a test proving null flows through to the resolver. 🤖 Generated with Claude Code --- src/Execution/Arguments/SaveModel.php | 4 -- .../Schema/Directives/CreateDirectiveTest.php | 47 +++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/Execution/Arguments/SaveModel.php b/src/Execution/Arguments/SaveModel.php index 21e70a7798..b6660c453a 100644 --- a/src/Execution/Arguments/SaveModel.php +++ b/src/Execution/Arguments/SaveModel.php @@ -63,10 +63,6 @@ public function __invoke($model, $args): Model } foreach ($preSave->arguments as $nested) { - if ($nested->value === null) { - continue; - } - $resolver = $nested->resolver; assert($resolver instanceof PreSaveArgResolver, 'Resolver must be a PreSaveArgResolver because we partitioned for it.'); $resolver($model, $nested->value); diff --git a/tests/Integration/Schema/Directives/CreateDirectiveTest.php b/tests/Integration/Schema/Directives/CreateDirectiveTest.php index 08bd7567c4..072198102b 100644 --- a/tests/Integration/Schema/Directives/CreateDirectiveTest.php +++ b/tests/Integration/Schema/Directives/CreateDirectiveTest.php @@ -635,4 +635,51 @@ public function testPreSaveArgResolverSetsForeignKeyBeforeSave(): void ], ]); } + + public function testPreSaveArgResolverReceivesNullForDisassociation(): void + { + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Task { + id: ID! + name: String! + user: User @belongsTo + } + + type User { + id: ID! + } + + type Mutation { + createTask(input: CreateTaskInput! @spread): Task @create + } + + input CreateTaskInput { + name: String! + owner: ID @connectRelated(relation: "user") + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createTask(input: { + name: "My task" + owner: null + }) { + id + name + user { + id + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createTask' => [ + 'id' => '1', + 'name' => 'My task', + 'user' => null, + ], + ], + ]); + } } From e09e9ab108f80ccec31dfb0b8203b2d5c95d789d Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 29 Jun 2026 18:14:39 +0200 Subject: [PATCH 15/47] Give PreSaveArgResolver directives precedence over implicit relation detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract preSaveArgResolvers before relationMethods in SaveModel so a directive-annotated field is never captured by name-based relation detection. Adds a test using field name "user" matching the model method directly (no relation: arg needed). 🤖 Generated with Claude Code --- src/Execution/Arguments/SaveModel.php | 6 +-- .../Schema/Directives/CreateDirectiveTest.php | 51 +++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/Execution/Arguments/SaveModel.php b/src/Execution/Arguments/SaveModel.php index b6660c453a..cd3f5a0252 100644 --- a/src/Execution/Arguments/SaveModel.php +++ b/src/Execution/Arguments/SaveModel.php @@ -24,9 +24,11 @@ public function __construct( */ public function __invoke($model, $args): Model { + [$preSave, $remaining] = ArgPartitioner::preSaveArgResolvers($args); + // Extract $morphTo first, as MorphTo extends BelongsTo [$morphTo, $remaining] = ArgPartitioner::relationMethods( - $args, + $remaining, $model, MorphTo::class, ); @@ -37,8 +39,6 @@ public function __invoke($model, $args): Model BelongsTo::class, ); - [$preSave, $remaining] = ArgPartitioner::preSaveArgResolvers($remaining); - $argsToFill = $remaining->toArray(); // Use all the remaining attributes and fill the model diff --git a/tests/Integration/Schema/Directives/CreateDirectiveTest.php b/tests/Integration/Schema/Directives/CreateDirectiveTest.php index 072198102b..af6b2b7700 100644 --- a/tests/Integration/Schema/Directives/CreateDirectiveTest.php +++ b/tests/Integration/Schema/Directives/CreateDirectiveTest.php @@ -636,6 +636,57 @@ public function testPreSaveArgResolverSetsForeignKeyBeforeSave(): void ]); } + public function testPreSaveArgResolverTakesPrecedenceOverImplicitRelationDetection(): void + { + $user = factory(User::class)->create(); + + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Task { + id: ID! + name: String! + user: User @belongsTo + } + + type User { + id: ID! + } + + type Mutation { + createTask(input: CreateTaskInput! @spread): Task @create + } + + input CreateTaskInput { + name: String! + user: ID @connectRelated + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<id}" + }) { + id + name + user { + id + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createTask' => [ + 'id' => '1', + 'name' => 'My task', + 'user' => [ + 'id' => "{$user->id}", + ], + ], + ], + ]); + } + public function testPreSaveArgResolverReceivesNullForDisassociation(): void { $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' From 29d3f6196fdf2fa22a140102e15ad23eef9b1674 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 29 Jun 2026 18:17:31 +0200 Subject: [PATCH 16/47] Add @api to ArgResolver and PreSaveArgResolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are documented user extension points for custom directives. 🤖 Generated with Claude Code --- src/Support/Contracts/ArgResolver.php | 1 + src/Support/Contracts/PreSaveArgResolver.php | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/Support/Contracts/ArgResolver.php b/src/Support/Contracts/ArgResolver.php index 665fadc525..8756f919ee 100644 --- a/src/Support/Contracts/ArgResolver.php +++ b/src/Support/Contracts/ArgResolver.php @@ -2,6 +2,7 @@ namespace Nuwave\Lighthouse\Support\Contracts; +/** @api */ interface ArgResolver { /** diff --git a/src/Support/Contracts/PreSaveArgResolver.php b/src/Support/Contracts/PreSaveArgResolver.php index ee8c9c7c52..c8d1e5a533 100644 --- a/src/Support/Contracts/PreSaveArgResolver.php +++ b/src/Support/Contracts/PreSaveArgResolver.php @@ -5,5 +5,7 @@ /** * Resolvers implementing this interface are invoked before $model->save(), * allowing them to set foreign keys on the parent model (e.g. BelongsTo). + * + * @api */ interface PreSaveArgResolver extends ArgResolver {} From 2cd91655c9e0a162f79d354c97a52dd3da71659b Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 30 Jun 2026 08:50:18 +0200 Subject: [PATCH 17/47] Add SaveAwareArgResolver design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the interface design for dynamic pre/post-save timing in mutation ArgResolvers, replacing the static PreSaveArgResolver marker interface. 🤖 Generated with Claude Code --- ...26-06-30-save-aware-arg-resolver-design.md | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-30-save-aware-arg-resolver-design.md diff --git a/docs/superpowers/specs/2026-06-30-save-aware-arg-resolver-design.md b/docs/superpowers/specs/2026-06-30-save-aware-arg-resolver-design.md new file mode 100644 index 0000000000..d5a8c7dc8c --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-save-aware-arg-resolver-design.md @@ -0,0 +1,165 @@ +# SaveAwareArgResolver Design + +## Problem + +`@upsert` (and `@create`/`@update`) on `INPUT_FIELD_DEFINITION` always runs post-save. +When the field targets a BelongsTo relation, it needs to run before the parent saves so the FK is available. +Custom directives that set model attributes from complex input (e.g. geocoding) also need pre-save timing without being relation-bound. + +## Constraints + +- `ArgResolver::__invoke(mixed $root, mixed $value)` -- root is `mixed`, not always a Model. +- Pre/post-save is only meaningful inside `SaveModel`'s orchestration. +- Post-save must stay default for backwards compatibility. +- A static marker interface doesn't work for `@upsert` because the same directive handles both BelongsTo (pre) and HasMany (post). +- limes-api extends `ArgPartitioner` with a custom partitioner -- the system is used beyond `SaveModel`. + +## Interface + +```php +namespace Nuwave\Lighthouse\Support\Contracts; + +use Illuminate\Database\Eloquent\Model; + +/** @api */ +interface SaveAwareArgResolver extends ArgResolver +{ + /** PHPDoc TBD — describes when the orchestrator calls this and what true/false means. */ + public function runBeforeSave(Model $model): bool; +} +``` + +- Extends `ArgResolver` -- a specialization, not a replacement. +- `@api` -- stability guarantee, consumers can implement this. +- Receives the Model so the decision can be contextual (relation type introspection). +- Replaces `PreSaveArgResolver` (branch-only, never shipped). + +## Implementors + +### ModelMutationDirective + +Extracts a `relationName()` method (reused by `__invoke()` and `runBeforeSave()`): + +```php +protected function relationName(): string +{ + return $this->directiveArgValue('relation', $this->nodeName()); +} + +public function runBeforeSave(Model $model): bool +{ + return ArgPartitioner::methodReturnsRelation( + new \ReflectionClass($model), + $this->relationName(), + BelongsTo::class, + ); +} +``` + +BelongsTo/MorphTo returns true (pre-save), everything else returns false (post-save). + +### Custom directives (e.g. @geocode test fixture) + +```php +public function runBeforeSave(Model $model): bool +{ + return true; +} +``` + +Always pre-save -- sets attributes on the model from complex input. + +## Orchestration + +### nestedArgResolvers (name kept) + +Extended to exclude `SaveAwareArgResolver` resolvers where `runBeforeSave()` returns true. +Only checks when root is a Model -- non-Model contexts skip the check entirely: + +```php +public static function nestedArgResolvers(ArgumentSet $argumentSet, mixed $root): array +{ + // ... attach resolvers (unchanged) ... + + return static::partition( + $argumentSet, + static function (string $name, Argument $argument) use ($root): bool { + $resolver = $argument->resolver; + if ($resolver === null) { + return false; + } + if ($resolver instanceof SaveAwareArgResolver + && $root instanceof Model + && $resolver->runBeforeSave($root)) { + return false; + } + return true; + }, + ); +} +``` + +### SaveModel + +Extracts pre-save resolvers and runs them before `$model->save()`: + +```php +[$preSave, $remaining] = ArgPartitioner::preSaveArgResolvers($remaining, $model); + +// ... fill model, resolve implicit BelongsTo/MorphTo ... + +foreach ($preSave->arguments as $nested) { + $resolver = $nested->resolver; + assert($resolver instanceof SaveAwareArgResolver); + $resolver($model, $nested->value); +} + +// ... save ... +``` + +Pre-save extraction happens before implicit `relationMethods(BelongsTo)` so directive-annotated fields aren't captured by name-based relation detection. + +### Non-Model contexts (@nest) + +When root is not a Model, `SaveAwareArgResolver` resolvers are treated as regular post-save. +The interface is inert outside a Model/save context. + +## Test Coverage + +1. **BelongsTo via @upsert on INPUT_FIELD_DEFINITION** -- creates the related model and associates FK before parent saves. +2. **@geocode custom directive** -- non-relation `SaveAwareArgResolver` that always returns true. Takes complex input, sets lat/lng on the model. Verifies attributes are present in a single save. +3. **SaveAwareArgResolver inside @nest** -- root is not a Model. Verifies the resolver still runs (post-save path), doesn't crash. +4. **Existing @upsert on HasMany** -- continues to pass (same directive, post-save timing). + +## Migration & BC + +From master: + +- **nestedArgResolvers** -- name kept, logic extended with `SaveAwareArgResolver` check. +- **SaveModel** -- gains pre-save extraction and execution loop. +- **ModelMutationDirective** -- implements `SaveAwareArgResolver`, adds `relationName()`. + +Added: + +- `SaveAwareArgResolver` interface (new, `@api`). +- `@geocode` test fixture directive. +- Tests for all three scenarios above. + +Removed (branch-only, never shipped): + +- `PreSaveArgResolver` interface. +- `@connectRelated` test fixture directive. +- `preSaveArgResolvers()` static method (folded into `nestedArgResolvers`). +- The rename to `postSaveArgResolvers` in `ResolveNested`. + +Unchanged: + +- `nestedArgResolvers` name and signature. +- `ResolveNested` default partitioner reference. +- `ArgResolver` interface. +- Implicit relation detection in `attachNestedArgResolver`. +- limes-api's extended `ArgPartitioner` -- unaffected. + +## Changelog + +`Added` -- `SaveAwareArgResolver` interface for directives that need control over pre/post-save timing in mutations. From fff4bad25ddb435b876e25cf9439b2ca59244765 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 30 Jun 2026 09:08:16 +0200 Subject: [PATCH 18/47] Remove PreSaveArgResolver in preparation for SaveAwareArgResolver --- .../2026-06-30-save-aware-arg-resolver.md | 963 ++++++++++++++++++ ...6-30-save-aware-arg-resolver.md.tasks.json | 61 ++ ...26-06-30-save-aware-arg-resolver-design.md | 21 +- src/Execution/Arguments/ArgPartitioner.php | 43 +- src/Execution/Arguments/ResolveNested.php | 7 +- src/Execution/Arguments/SaveModel.php | 11 +- src/Support/Contracts/ArgResolver.php | 1 - src/Support/Contracts/PreSaveArgResolver.php | 11 - .../Schema/Directives/CreateDirectiveTest.php | 149 --- .../Arguments/ArgPartitionerTest.php | 38 +- .../Arguments/Fixtures/PreNested.php | 18 - .../Directives/ConnectRelatedDirective.php | 29 - 12 files changed, 1049 insertions(+), 303 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-30-save-aware-arg-resolver.md create mode 100644 docs/superpowers/plans/2026-06-30-save-aware-arg-resolver.md.tasks.json delete mode 100644 src/Support/Contracts/PreSaveArgResolver.php delete mode 100644 tests/Unit/Execution/Arguments/Fixtures/PreNested.php delete mode 100644 tests/Utils/Directives/ConnectRelatedDirective.php diff --git a/docs/superpowers/plans/2026-06-30-save-aware-arg-resolver.md b/docs/superpowers/plans/2026-06-30-save-aware-arg-resolver.md new file mode 100644 index 0000000000..cf914be7f3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-save-aware-arg-resolver.md @@ -0,0 +1,963 @@ +# SaveAwareArgResolver Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the branch-only `PreSaveArgResolver` with `SaveAwareArgResolver` — an interface that lets directives dynamically decide pre/post-save timing based on the Model context. + +**Architecture:** `SaveAwareArgResolver extends ArgResolver` with a single `runBeforeSave(Model $model): bool` method. `ArgPartitioner::nestedArgResolvers()` excludes resolvers that return true (passing them through to `SaveModel`). `SaveModel` extracts and invokes them before `$model->save()`. `ModelMutationDirective` implements the interface using relation-type introspection. + +**Tech Stack:** PHP 8.2, Laravel/Eloquent, PHPUnit, PHPStan level 8 + +**User decisions (already made):** +- "Keep `nestedArgResolvers` name — avoid the rename to `postSaveArgResolvers`" +- "Interface should be `SaveAwareArgResolver` with `runBeforeSave(Model $model): bool`" +- "Use `@geocode` test fixture for non-relation pre-save case" +- "Relation-name logic extracted into `relationName()` method on ModelMutationDirective" + +--- + +### Task 1: Reset branch to master baseline for affected files + +**Goal:** Remove all branch-only `PreSaveArgResolver` code so we start clean from master for the new design. + +**Files:** +- Delete: `src/Support/Contracts/PreSaveArgResolver.php` +- Delete: `tests/Utils/Directives/ConnectRelatedDirective.php` +- Delete: `tests/Unit/Execution/Arguments/Fixtures/PreNested.php` +- Revert: `src/Execution/Arguments/ArgPartitioner.php` (to master) +- Revert: `src/Execution/Arguments/ResolveNested.php` (to master) +- Revert: `src/Execution/Arguments/SaveModel.php` (to master) +- Revert: `src/Support/Contracts/ArgResolver.php` (to master) +- Revert: `tests/Unit/Execution/Arguments/ArgPartitionerTest.php` (to master) +- Revert: `tests/Integration/Schema/Directives/CreateDirectiveTest.php` (to master) + +**Acceptance Criteria:** +- [ ] `PreSaveArgResolver.php` no longer exists +- [ ] `ConnectRelatedDirective.php` no longer exists +- [ ] `PreNested.php` no longer exists +- [ ] `ArgPartitioner.php`, `ResolveNested.php`, `SaveModel.php` match master +- [ ] `ArgResolver.php` matches master (no `@api` yet — added in Task 2) +- [ ] Tests pass: `vendor/bin/phpunit tests/Unit/Execution/Arguments/ArgPartitionerTest.php` + +**Verify:** `docker compose run --rm php vendor/bin/phpunit tests/Unit/Execution/Arguments/ArgPartitionerTest.php` → PASS + +**Steps:** + +- [ ] **Step 1: Revert src files to master** + +```bash +git -C /home/bfranke/projects/lighthouse checkout master -- \ + src/Execution/Arguments/ArgPartitioner.php \ + src/Execution/Arguments/ResolveNested.php \ + src/Execution/Arguments/SaveModel.php \ + src/Support/Contracts/ArgResolver.php \ + tests/Unit/Execution/Arguments/ArgPartitionerTest.php \ + tests/Integration/Schema/Directives/CreateDirectiveTest.php +``` + +- [ ] **Step 2: Delete branch-only files** + +```bash +git -C /home/bfranke/projects/lighthouse rm \ + src/Support/Contracts/PreSaveArgResolver.php \ + tests/Utils/Directives/ConnectRelatedDirective.php \ + tests/Unit/Execution/Arguments/Fixtures/PreNested.php +``` + +- [ ] **Step 3: Run tests to verify clean state** + +Run: `docker compose run --rm php vendor/bin/phpunit tests/Unit/Execution/Arguments/ArgPartitionerTest.php` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git -C /home/bfranke/projects/lighthouse add -A && git -C /home/bfranke/projects/lighthouse commit -m "Remove PreSaveArgResolver in preparation for SaveAwareArgResolver" +``` + +--- + +### Task 2: Create SaveAwareArgResolver interface + +**Goal:** Define the new interface with `@api` stability guarantee. + +**Files:** +- Create: `src/Support/Contracts/SaveAwareArgResolver.php` +- Modify: `src/Support/Contracts/ArgResolver.php` (add `@api`) + +**Acceptance Criteria:** +- [ ] Interface extends `ArgResolver` +- [ ] Has `runBeforeSave(Model $model): bool` method +- [ ] Both `ArgResolver` and `SaveAwareArgResolver` have `@api` annotation +- [ ] PHPStan passes + +**Verify:** `docker compose run --rm php vendor/bin/phpstan analyse src/Support/Contracts/SaveAwareArgResolver.php src/Support/Contracts/ArgResolver.php` → OK + +**Steps:** + +- [ ] **Step 1: Add `@api` to ArgResolver** + +In `src/Support/Contracts/ArgResolver.php`, add `@api` to the class docblock: + +```php + $value the slice of arguments that belongs to this nested resolver + * + * @return mixed|void|null May return the modified $root + */ + public function __invoke(mixed $root, mixed $value); +} +``` + +- [ ] **Step 2: Create SaveAwareArgResolver** + +Create `src/Support/Contracts/SaveAwareArgResolver.php`: + +```php +save(), allowing it + * to set attributes or foreign keys on the model. + * When false, the resolver runs after the model is saved (the default + * for any ArgResolver that does not implement this interface). + * + * Only consulted when the root is a Model inside SaveModel's orchestration. + * In non-Model contexts (e.g. @nest), this method is not called and the + * resolver runs in the default post-save position. + */ + public function runBeforeSave(Model $model): bool; +} +``` + +- [ ] **Step 3: Run PHPStan** + +Run: `docker compose run --rm php vendor/bin/phpstan analyse src/Support/Contracts/` +Expected: OK (no errors) + +- [ ] **Step 4: Commit** + +```bash +git -C /home/bfranke/projects/lighthouse add src/Support/Contracts/SaveAwareArgResolver.php src/Support/Contracts/ArgResolver.php && git -C /home/bfranke/projects/lighthouse commit -m "Add SaveAwareArgResolver interface" +``` + +--- + +### Task 3: Implement SaveAwareArgResolver in ModelMutationDirective + +**Goal:** Make `@upsert`, `@create`, and `@update` dynamically decide pre/post-save timing based on relation type. + +**Files:** +- Modify: `src/Schema/Directives/ModelMutationDirective.php` + +**Acceptance Criteria:** +- [ ] `ModelMutationDirective` implements `SaveAwareArgResolver` +- [ ] New `relationName(): string` method extracted from `__invoke()` +- [ ] `runBeforeSave()` returns true for BelongsTo relations (which includes MorphTo) +- [ ] `__invoke()` uses `$this->relationName()` instead of inline logic +- [ ] PHPStan passes + +**Verify:** `docker compose run --rm php vendor/bin/phpstan analyse src/Schema/Directives/ModelMutationDirective.php` → OK + +**Steps:** + +- [ ] **Step 1: Add interface and implement** + +Modify `src/Schema/Directives/ModelMutationDirective.php`: + +```php +directiveArgValue( + 'relation', + $this->nodeName(), + ); + } + + public function runBeforeSave(Model $model): bool + { + return ArgPartitioner::methodReturnsRelation( + new \ReflectionClass($model), + $this->relationName(), + BelongsTo::class, + ); + } + + /** + * @param Model $model + * @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet|array<\Nuwave\Lighthouse\Execution\Arguments\ArgumentSet> $args + * + * @return \Illuminate\Database\Eloquent\Model|array<\Illuminate\Database\Eloquent\Model> + */ + public function __invoke($model, $args): mixed + { + $relation = $model->{$this->relationName()}(); + assert($relation instanceof Relation); + + $related = $relation->make(); // @phpstan-ignore method.notFound (Relation delegates to Builder) + + return $this->executeMutation($related, $args, $relation); + } + + /** + * @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet|array<\Nuwave\Lighthouse\Execution\Arguments\ArgumentSet> $args + * @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null $parentRelation + * + * @return \Illuminate\Database\Eloquent\Model|array<\Illuminate\Database\Eloquent\Model> + */ + protected function executeMutation(Model $model, ArgumentSet|array $args, ?Relation $parentRelation = null): Model|array + { + $update = new ResolveNested($this->makeExecutionFunction($parentRelation)); + + return Utils::mapEach( + static fn (ArgumentSet $argumentSet): mixed => $update($model->newInstance(), $argumentSet), + $args, + ); + } + + /** + * Prepare the execution function for a mutation on a model. + * + * @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null $parentRelation + */ + abstract protected function makeExecutionFunction(?Relation $parentRelation = null): callable; +} +``` + +- [ ] **Step 2: Run PHPStan** + +Run: `docker compose run --rm php vendor/bin/phpstan analyse src/Schema/Directives/ModelMutationDirective.php` +Expected: OK + +- [ ] **Step 3: Commit** + +```bash +git -C /home/bfranke/projects/lighthouse add src/Schema/Directives/ModelMutationDirective.php && git -C /home/bfranke/projects/lighthouse commit -m "Implement SaveAwareArgResolver in ModelMutationDirective" +``` + +--- + +### Task 4: Update ArgPartitioner and SaveModel orchestration + +**Goal:** Make `nestedArgResolvers` exclude pre-save resolvers (passing them to SaveModel), and make SaveModel extract and invoke them before save. + +**Files:** +- Modify: `src/Execution/Arguments/ArgPartitioner.php` +- Modify: `src/Execution/Arguments/SaveModel.php` + +**Acceptance Criteria:** +- [ ] `nestedArgResolvers` excludes `SaveAwareArgResolver` resolvers where `runBeforeSave($root)` is true (only when root is Model) +- [ ] New `preSaveNestedArgResolvers(ArgumentSet, Model): array` method on ArgPartitioner +- [ ] `SaveModel` extracts pre-save resolvers before implicit BelongsTo detection +- [ ] `SaveModel` invokes pre-save resolvers before `$model->save()` +- [ ] PHPStan passes for both files + +**Verify:** `docker compose run --rm php vendor/bin/phpstan analyse src/Execution/Arguments/ArgPartitioner.php src/Execution/Arguments/SaveModel.php` → OK + +**Steps:** + +- [ ] **Step 1: Update ArgPartitioner::nestedArgResolvers** + +In `src/Execution/Arguments/ArgPartitioner.php`, modify the `nestedArgResolvers` method and add `preSaveNestedArgResolvers`: + +```php +use Nuwave\Lighthouse\Support\Contracts\SaveAwareArgResolver; +``` + +Replace the `nestedArgResolvers` method: + +```php + /** + * Partition the arguments into nested (post-save) and regular. + * + * Resolvers implementing SaveAwareArgResolver that return true from + * runBeforeSave() are excluded from the nested set when the root is a Model, + * allowing SaveModel to handle them before persisting. + * + * @return array{ + * 0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, + * 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, + * } + */ + public static function nestedArgResolvers(ArgumentSet $argumentSet, mixed $root): array + { + $model = $root instanceof Model + ? new \ReflectionClass($root) + : null; + + foreach ($argumentSet->arguments as $name => $argument) { + static::attachNestedArgResolver($name, $argument, $model); + } + + return static::partition( + $argumentSet, + static function (string $name, Argument $argument) use ($root): bool { + $resolver = $argument->resolver; + if ($resolver === null) { + return false; + } + + if ($resolver instanceof SaveAwareArgResolver + && $root instanceof Model + && $resolver->runBeforeSave($root) + ) { + return false; + } + + return true; + }, + ); + } + + /** + * Partition arguments into those with a pre-save resolver and the rest. + * + * @return array{ + * 0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, + * 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, + * } + */ + public static function preSaveNestedArgResolvers(ArgumentSet $argumentSet, Model $model): array + { + return static::partition( + $argumentSet, + static fn (string $name, Argument $argument): bool => $argument->resolver instanceof SaveAwareArgResolver + && $argument->resolver->runBeforeSave($model), + ); + } +``` + +- [ ] **Step 2: Update SaveModel** + +In `src/Execution/Arguments/SaveModel.php`, add the import and pre-save logic: + +```php +use Nuwave\Lighthouse\Support\Contracts\SaveAwareArgResolver; +``` + +Replace the `__invoke` method body: + +```php + /** + * @param Model $model + * @param ArgumentSet $args + */ + public function __invoke($model, $args): Model + { + [$preSave, $remaining] = ArgPartitioner::preSaveNestedArgResolvers($args, $model); + + // Extract $morphTo first, as MorphTo extends BelongsTo + [$morphTo, $remaining] = ArgPartitioner::relationMethods( + $remaining, + $model, + MorphTo::class, + ); + + [$belongsTo, $remaining] = ArgPartitioner::relationMethods( + $remaining, + $model, + BelongsTo::class, + ); + + $argsToFill = $remaining->toArray(); + + // Use all the remaining attributes and fill the model + if (config('lighthouse.force_fill')) { + $model->forceFill($argsToFill); + } else { + $model->fill($argsToFill); + } + + foreach ($belongsTo->arguments as $relationName => $nestedOperations) { + $belongsTo = $model->{$relationName}(); + assert($belongsTo instanceof BelongsTo); + $belongsToResolver = new ResolveNested(new NestedBelongsTo($belongsTo)); + $belongsToResolver($model, $nestedOperations->value); + } + + foreach ($morphTo->arguments as $relationName => $nestedOperations) { + $morphTo = $model->{$relationName}(); + assert($morphTo instanceof MorphTo); + $morphToResolver = new ResolveNested(new NestedMorphTo($morphTo)); + $morphToResolver($model, $nestedOperations->value); + } + + foreach ($preSave->arguments as $nested) { + $resolver = $nested->resolver; + assert($resolver instanceof SaveAwareArgResolver, 'Resolver must be a SaveAwareArgResolver because we partitioned for it.'); + $resolver($model, $nested->value); + } + + if ($this->parentRelation instanceof HasOneOrMany) { + // If we are already resolving a nested create, we might + // already have an instance of the parent relation available. + // In that case, use it to set the current model as a child. + $this->parentRelation->save($model); + + return $model; + } + + $model->save(); + + if ($this->parentRelation instanceof BelongsTo) { + $childModel = $this->parentRelation->associate($model); + + // If the child Model does not exist (still to be saved), + // a save could break any pending belongsTo relations that still + // need to be created and associated with it. + if ($childModel->exists) { + $childModel->save(); + } + } + + if ($this->parentRelation instanceof BelongsToMany) { + $this->parentRelation->syncWithoutDetaching($model); + } + + return $model; + } +``` + +- [ ] **Step 3: Run PHPStan** + +Run: `docker compose run --rm php vendor/bin/phpstan analyse src/Execution/Arguments/ArgPartitioner.php src/Execution/Arguments/SaveModel.php` +Expected: OK + +- [ ] **Step 4: Commit** + +```bash +git -C /home/bfranke/projects/lighthouse add src/Execution/Arguments/ArgPartitioner.php src/Execution/Arguments/SaveModel.php && git -C /home/bfranke/projects/lighthouse commit -m "Route SaveAwareArgResolver through pre-save in SaveModel" +``` + +--- + +### Task 5: Test @upsert on BelongsTo INPUT_FIELD_DEFINITION + +**Goal:** Prove that `@upsert` on a BelongsTo field creates the related model and sets the FK before the parent saves. + +**Files:** +- Modify: `tests/Integration/Schema/Directives/CreateDirectiveTest.php` + +**Acceptance Criteria:** +- [ ] Test creates a Task with `@upsert` on a BelongsTo `user` field +- [ ] FK is set before parent save (no integrity violation) +- [ ] Related model is created and associated correctly +- [ ] Test for precedence over implicit relation detection (argument named same as relation) + +**Verify:** `docker compose run --rm php vendor/bin/phpunit --filter=testUpsertBelongsToBeforeSave` → PASS + +**Steps:** + +- [ ] **Step 1: Write integration test** + +Add to `tests/Integration/Schema/Directives/CreateDirectiveTest.php`: + +```php + public function testUpsertBelongsToBeforeSave(): void + { + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Task { + id: ID! + name: String! + user: User @belongsTo + } + + type User { + id: ID! + name: String! + } + + type Mutation { + createTask(input: CreateTaskInput! @spread): Task @create + } + + input CreateTaskInput { + name: String! + user: CreateUserInput @upsert + } + + input CreateUserInput { + id: ID + name: String! + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createTask(input: { + name: "My task" + user: { + name: "New User" + } + }) { + id + name + user { + id + name + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createTask' => [ + 'name' => 'My task', + 'user' => [ + 'id' => '1', + 'name' => 'New User', + ], + ], + ], + ]); + } + + public function testUpsertBelongsToTakesPrecedenceOverImplicitRelation(): void + { + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Task { + id: ID! + name: String! + user: User @belongsTo + } + + type User { + id: ID! + name: String! + } + + type Mutation { + createTask(input: CreateTaskInput! @spread): Task @create + } + + input CreateTaskInput { + name: String! + user: CreateUserInput @upsert + } + + input CreateUserInput { + id: ID + name: String! + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createTask(input: { + name: "My task" + user: { + name: "Created via directive" + } + }) { + id + name + user { + name + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createTask' => [ + 'name' => 'My task', + 'user' => [ + 'name' => 'Created via directive', + ], + ], + ], + ]); + } +``` + +- [ ] **Step 2: Run tests** + +Run: `docker compose run --rm php vendor/bin/phpunit --filter=testUpsertBelongsTo` +Expected: PASS (both tests) + +- [ ] **Step 3: Commit** + +```bash +git -C /home/bfranke/projects/lighthouse add tests/Integration/Schema/Directives/CreateDirectiveTest.php && git -C /home/bfranke/projects/lighthouse commit -m "Test @upsert on BelongsTo INPUT_FIELD_DEFINITION" +``` + +--- + +### Task 6: Test @geocode custom directive (non-relation pre-save) + +**Goal:** Prove that a custom `SaveAwareArgResolver` that always returns `true` can set model attributes before save without being relation-bound. + +**Files:** +- Create: `tests/Utils/Directives/GeocodeDirective.php` +- Modify: `tests/Integration/Schema/Directives/CreateDirectiveTest.php` + +**Acceptance Criteria:** +- [ ] `@geocode` directive implements `SaveAwareArgResolver` with `runBeforeSave() => true` +- [ ] Takes an `AddressInput` argument, sets `latitude` and `longitude` on the model +- [ ] Integration test proves attributes are persisted in a single save + +**Verify:** `docker compose run --rm php vendor/bin/phpunit --filter=testGeocodePreSaveArgResolver` → PASS + +**Steps:** + +- [ ] **Step 1: Create GeocodeDirective test fixture** + +Create `tests/Utils/Directives/GeocodeDirective.php`: + +```php +toArray(); + $model->setAttribute('latitude', $address['lat'] ?? 0.0); + $model->setAttribute('longitude', $address['lng'] ?? 0.0); + } +} +``` + +- [ ] **Step 2: Add migration for lat/lng columns on users table** + +Check if there's an existing way to add columns in tests. The test models use the existing migrations in `tests/database/migrations/`. We need a model with lat/lng columns. The `User` model's migration is at `tests/database/migrations/2018_02_28_000000_create_testbench_users_table.php`. Rather than modifying existing migrations, use a model that can have arbitrary attributes (Users table supports `$guarded = []`). + +Actually, Lighthouse tests use `$model->fill()` / `$model->forceFill()` depending on config. The test should use a table that has these columns. Let's check what columns the users table has: + +Look at the users migration — if it doesn't have lat/lng we need a different approach. Since we can't modify existing migrations without risk, let's use a simpler approach: create an inline migration in the test setUp, or use a model with JSON/text columns we can repurpose. + +Alternatively — the `@geocode` directive sets attributes via `setAttribute`. If the model uses `$guarded = []` and the table has the columns, it works. Since we can't guarantee lat/lng columns, let's set existing string columns on the User model instead (e.g. repurpose existing nullable columns). + +Better approach: use `Task` model which has `name` and `guard_test` columns, and have geocode set a known column. But that's contrived. + +Simplest: add a test migration. Lighthouse tests already have a pattern for this — add a new migration file: + +Create `tests/database/migrations/2026_06_30_000000_add_geocode_columns_to_users_table.php`: + +```php +float('latitude')->nullable(); + $table->float('longitude')->nullable(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn('latitude', 'longitude'); + }); + } +}; +``` + +- [ ] **Step 3: Write integration test** + +Add to `tests/Integration/Schema/Directives/CreateDirectiveTest.php`: + +```php + public function testGeocodePreSaveArgResolver(): void + { + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + name: String! + latitude: Float + longitude: Float + } + + type Mutation { + createUser(input: CreateUserInput! @spread): User @create + } + + input CreateUserInput { + name: String! + location: LocationInput @geocode + } + + input LocationInput { + lat: Float! + lng: Float! + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createUser(input: { + name: "Geo User" + location: { + lat: 48.1351 + lng: 11.5820 + } + }) { + id + name + latitude + longitude + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createUser' => [ + 'name' => 'Geo User', + 'latitude' => 48.1351, + 'longitude' => 11.582, + ], + ], + ]); + } +``` + +- [ ] **Step 4: Run test** + +Run: `docker compose run --rm php vendor/bin/phpunit --filter=testGeocodePreSaveArgResolver` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git -C /home/bfranke/projects/lighthouse add tests/Utils/Directives/GeocodeDirective.php tests/database/migrations/2026_06_30_000000_add_geocode_columns_to_users_table.php tests/Integration/Schema/Directives/CreateDirectiveTest.php && git -C /home/bfranke/projects/lighthouse commit -m "Test @geocode non-relation SaveAwareArgResolver" +``` + +--- + +### Task 7: Test SaveAwareArgResolver inside @nest (non-Model root) + +**Goal:** Prove that a `SaveAwareArgResolver` inside `@nest` still runs correctly when the root is not yet a Model (falls back to post-save position). + +**Files:** +- Modify: `tests/Unit/Execution/Arguments/ArgPartitionerTest.php` + +**Acceptance Criteria:** +- [ ] Unit test creates a `SaveAwareArgResolver` fixture +- [ ] Passes non-Model root to `nestedArgResolvers` +- [ ] The resolver ends up in the "nested" (post-save) partition, not excluded +- [ ] No crash or error + +**Verify:** `docker compose run --rm php vendor/bin/phpunit --filter=testSaveAwareArgResolverWithNonModelRoot` → PASS + +**Steps:** + +- [ ] **Step 1: Create test fixture** + +Create `tests/Unit/Execution/Arguments/Fixtures/SaveAwareNested.php`: + +```php +arguments['regular'] = $regular; + + $saveAware = new Argument(); + $saveAware->directives->push(new SaveAwareNested()); + $argumentSet->arguments['saveAware'] = $saveAware; + + [$nestedArgs, $regularArgs] = ArgPartitioner::nestedArgResolvers($argumentSet, null); + + $this->assertSame( + ['regular' => $regular], + $regularArgs->arguments, + ); + + $this->assertSame( + ['saveAware' => $saveAware], + $nestedArgs->arguments, + 'SaveAwareArgResolver should be in nested (post-save) set when root is not a Model', + ); + } + + public function testSaveAwareArgResolverWithModelRoot(): void + { + $argumentSet = new ArgumentSet(); + + $regular = new Argument(); + $argumentSet->arguments['regular'] = $regular; + + $saveAware = new Argument(); + $saveAware->directives->push(new SaveAwareNested()); + $argumentSet->arguments['saveAware'] = $saveAware; + + [$nestedArgs, $regularArgs] = ArgPartitioner::nestedArgResolvers($argumentSet, new User()); + + $this->assertSame( + ['regular' => $regular, 'saveAware' => $saveAware], + $regularArgs->arguments, + 'SaveAwareArgResolver with runBeforeSave=true should be excluded from nested set when root is Model', + ); + + $this->assertSame( + [], + $nestedArgs->arguments, + ); + } +``` + +- [ ] **Step 3: Run tests** + +Run: `docker compose run --rm php vendor/bin/phpunit tests/Unit/Execution/Arguments/ArgPartitionerTest.php` +Expected: PASS (all tests including existing ones) + +- [ ] **Step 4: Commit** + +```bash +git -C /home/bfranke/projects/lighthouse add tests/Unit/Execution/Arguments/Fixtures/SaveAwareNested.php tests/Unit/Execution/Arguments/ArgPartitionerTest.php && git -C /home/bfranke/projects/lighthouse commit -m "Test SaveAwareArgResolver partitioning with Model and non-Model roots" +``` + +--- + +### Task 8: Update CHANGELOG and run full test suite + +**Goal:** Document the change and confirm nothing is broken. + +**Files:** +- Modify: `CHANGELOG.md` + +**Acceptance Criteria:** +- [ ] CHANGELOG has entry under `## Unreleased` → `Added` +- [ ] Full test suite passes +- [ ] PHPStan passes + +**Verify:** `docker compose run --rm php sh -c 'vendor/bin/phpstan && vendor/bin/phpunit'` → OK + all tests pass + +**Steps:** + +- [ ] **Step 1: Update CHANGELOG** + +Add under `## Unreleased` in `CHANGELOG.md`: + +```markdown +### Added + +- `SaveAwareArgResolver` interface for directives that need control over pre/post-save timing in mutations https://github.com/nuwave/lighthouse/pull/2777 +``` + +- [ ] **Step 2: Run full checks** + +Run: `docker compose run --rm php sh -c 'vendor/bin/phpstan && vendor/bin/phpunit'` +Expected: No errors, all tests pass + +- [ ] **Step 3: Run code fixer** + +Run: `docker compose run --rm php vendor/bin/php-cs-fixer fix` +Expected: No changes (or only formatting fixes) + +- [ ] **Step 4: Commit** + +```bash +git -C /home/bfranke/projects/lighthouse add CHANGELOG.md && git -C /home/bfranke/projects/lighthouse commit -m "Add CHANGELOG entry for SaveAwareArgResolver" +``` + +If php-cs-fixer made changes: + +```bash +git -C /home/bfranke/projects/lighthouse add -u && git -C /home/bfranke/projects/lighthouse commit -m "Apply php-cs-fixer changes" +``` diff --git a/docs/superpowers/plans/2026-06-30-save-aware-arg-resolver.md.tasks.json b/docs/superpowers/plans/2026-06-30-save-aware-arg-resolver.md.tasks.json new file mode 100644 index 0000000000..1ee7367801 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-save-aware-arg-resolver.md.tasks.json @@ -0,0 +1,61 @@ +{ + "planPath": "docs/superpowers/plans/2026-06-30-save-aware-arg-resolver.md", + "tasks": [ + { + "id": 5, + "subject": "Task 1: Reset branch to master baseline for affected files", + "status": "pending", + "description": "**Goal:** Remove all branch-only `PreSaveArgResolver` code so we start clean from master for the new design.\n\n**Files:**\n- Delete: `src/Support/Contracts/PreSaveArgResolver.php`\n- Delete: `tests/Utils/Directives/ConnectRelatedDirective.php`\n- Delete: `tests/Unit/Execution/Arguments/Fixtures/PreNested.php`\n- Revert: `src/Execution/Arguments/ArgPartitioner.php` (to master)\n- Revert: `src/Execution/Arguments/ResolveNested.php` (to master)\n- Revert: `src/Execution/Arguments/SaveModel.php` (to master)\n- Revert: `src/Support/Contracts/ArgResolver.php` (to master)\n- Revert: `tests/Unit/Execution/Arguments/ArgPartitionerTest.php` (to master)\n- Revert: `tests/Integration/Schema/Directives/CreateDirectiveTest.php` (to master)\n\n**Acceptance Criteria:**\n- [ ] `PreSaveArgResolver.php` deleted\n- [ ] `ConnectRelatedDirective.php` deleted\n- [ ] `PreNested.php` deleted\n- [ ] Core src files match master\n- [ ] Tests pass\n\n**Verify:** `docker compose run --rm php vendor/bin/phpunit tests/Unit/Execution/Arguments/ArgPartitionerTest.php`\n\n```json:metadata\n{\"files\": [\"src/Support/Contracts/PreSaveArgResolver.php\", \"tests/Utils/Directives/ConnectRelatedDirective.php\", \"tests/Unit/Execution/Arguments/Fixtures/PreNested.php\", \"src/Execution/Arguments/ArgPartitioner.php\", \"src/Execution/Arguments/ResolveNested.php\", \"src/Execution/Arguments/SaveModel.php\"], \"verifyCommand\": \"docker compose run --rm php vendor/bin/phpunit tests/Unit/Execution/Arguments/ArgPartitionerTest.php\", \"acceptanceCriteria\": [\"PreSaveArgResolver.php deleted\", \"ConnectRelatedDirective.php deleted\", \"PreNested.php deleted\", \"Core src files match master\", \"Tests pass\"], \"modelTier\": \"mechanical\"}\n```" + }, + { + "id": 6, + "subject": "Task 2: Create SaveAwareArgResolver interface", + "status": "pending", + "blockedBy": [5], + "description": "**Goal:** Define the new interface with `@api` stability guarantee.\n\n**Files:**\n- Create: `src/Support/Contracts/SaveAwareArgResolver.php`\n- Modify: `src/Support/Contracts/ArgResolver.php` (add `@api`)\n\n**Acceptance Criteria:**\n- [ ] Interface extends `ArgResolver`\n- [ ] Has `runBeforeSave(Model $model): bool` method\n- [ ] Both have `@api` annotation\n- [ ] PHPStan passes\n\n**Verify:** `docker compose run --rm php vendor/bin/phpstan analyse src/Support/Contracts/SaveAwareArgResolver.php src/Support/Contracts/ArgResolver.php`\n\n```json:metadata\n{\"files\": [\"src/Support/Contracts/SaveAwareArgResolver.php\", \"src/Support/Contracts/ArgResolver.php\"], \"verifyCommand\": \"docker compose run --rm php vendor/bin/phpstan analyse src/Support/Contracts/SaveAwareArgResolver.php src/Support/Contracts/ArgResolver.php\", \"acceptanceCriteria\": [\"Interface extends ArgResolver\", \"Has runBeforeSave(Model) method\", \"Both have @api annotation\", \"PHPStan passes\"], \"modelTier\": \"mechanical\"}\n```" + }, + { + "id": 7, + "subject": "Task 3: Implement SaveAwareArgResolver in ModelMutationDirective", + "status": "pending", + "blockedBy": [6], + "description": "**Goal:** Make `@upsert`, `@create`, and `@update` dynamically decide pre/post-save timing based on relation type.\n\n**Files:**\n- Modify: `src/Schema/Directives/ModelMutationDirective.php`\n\n**Acceptance Criteria:**\n- [ ] `ModelMutationDirective` implements `SaveAwareArgResolver`\n- [ ] New `relationName(): string` method extracted from `__invoke()`\n- [ ] `runBeforeSave()` returns true for BelongsTo relations (which includes MorphTo)\n- [ ] `__invoke()` uses `$this->relationName()`\n- [ ] PHPStan passes\n\n**Verify:** `docker compose run --rm php vendor/bin/phpstan analyse src/Schema/Directives/ModelMutationDirective.php`\n\n```json:metadata\n{\"files\": [\"src/Schema/Directives/ModelMutationDirective.php\"], \"verifyCommand\": \"docker compose run --rm php vendor/bin/phpstan analyse src/Schema/Directives/ModelMutationDirective.php\", \"acceptanceCriteria\": [\"Implements SaveAwareArgResolver\", \"relationName() method extracted\", \"runBeforeSave returns true for BelongsTo\", \"__invoke uses relationName()\", \"PHPStan passes\"], \"modelTier\": \"standard\"}\n```" + }, + { + "id": 8, + "subject": "Task 4: Update ArgPartitioner and SaveModel orchestration", + "status": "pending", + "blockedBy": [6, 7], + "description": "**Goal:** Make `nestedArgResolvers` exclude pre-save resolvers (passing them to SaveModel), and make SaveModel extract and invoke them before save.\n\n**Files:**\n- Modify: `src/Execution/Arguments/ArgPartitioner.php`\n- Modify: `src/Execution/Arguments/SaveModel.php`\n\n**Acceptance Criteria:**\n- [ ] `nestedArgResolvers` excludes `SaveAwareArgResolver` resolvers where `runBeforeSave($root)` is true (only when root is Model)\n- [ ] New `preSaveNestedArgResolvers(ArgumentSet, Model): array` method on ArgPartitioner\n- [ ] `SaveModel` extracts pre-save resolvers before implicit BelongsTo detection\n- [ ] `SaveModel` invokes pre-save resolvers before `$model->save()`\n- [ ] PHPStan passes\n\n**Verify:** `docker compose run --rm php vendor/bin/phpstan analyse src/Execution/Arguments/ArgPartitioner.php src/Execution/Arguments/SaveModel.php`\n\n```json:metadata\n{\"files\": [\"src/Execution/Arguments/ArgPartitioner.php\", \"src/Execution/Arguments/SaveModel.php\"], \"verifyCommand\": \"docker compose run --rm php vendor/bin/phpstan analyse src/Execution/Arguments/ArgPartitioner.php src/Execution/Arguments/SaveModel.php\", \"acceptanceCriteria\": [\"nestedArgResolvers excludes pre-save resolvers when root is Model\", \"preSaveNestedArgResolvers method added\", \"SaveModel extracts pre-save before BelongsTo detection\", \"SaveModel invokes pre-save resolvers before save\", \"PHPStan passes\"], \"modelTier\": \"standard\"}\n```" + }, + { + "id": 9, + "subject": "Task 5: Test @upsert on BelongsTo INPUT_FIELD_DEFINITION", + "status": "pending", + "blockedBy": [8], + "description": "**Goal:** Prove that `@upsert` on a BelongsTo field creates the related model and sets the FK before the parent saves.\n\n**Files:**\n- Modify: `tests/Integration/Schema/Directives/CreateDirectiveTest.php`\n\n**Acceptance Criteria:**\n- [ ] Test creates a Task with `@upsert` on a BelongsTo `user` field\n- [ ] FK is set before parent save (no integrity violation)\n- [ ] Related model is created and associated correctly\n- [ ] Test for precedence over implicit relation detection\n\n**Verify:** `docker compose run --rm php vendor/bin/phpunit --filter=testUpsertBelongsTo`\n\n```json:metadata\n{\"files\": [\"tests/Integration/Schema/Directives/CreateDirectiveTest.php\"], \"verifyCommand\": \"docker compose run --rm php vendor/bin/phpunit --filter=testUpsertBelongsTo\", \"acceptanceCriteria\": [\"Test creates Task with @upsert on BelongsTo\", \"FK set before parent save\", \"Related model created and associated\", \"Precedence over implicit relation detection\"], \"modelTier\": \"standard\"}\n```" + }, + { + "id": 10, + "subject": "Task 6: Test @geocode custom directive (non-relation pre-save)", + "status": "pending", + "blockedBy": [8], + "description": "**Goal:** Prove that a custom `SaveAwareArgResolver` that always returns `true` can set model attributes before save without being relation-bound.\n\n**Files:**\n- Create: `tests/Utils/Directives/GeocodeDirective.php`\n- Create: `tests/database/migrations/2026_06_30_000000_add_geocode_columns_to_users_table.php`\n- Modify: `tests/Integration/Schema/Directives/CreateDirectiveTest.php`\n\n**Acceptance Criteria:**\n- [ ] `@geocode` directive implements `SaveAwareArgResolver` with `runBeforeSave() => true`\n- [ ] Takes an `AddressInput` argument, sets `latitude` and `longitude` on the model\n- [ ] Integration test proves attributes are persisted in a single save\n\n**Verify:** `docker compose run --rm php vendor/bin/phpunit --filter=testGeocodePreSaveArgResolver`\n\n```json:metadata\n{\"files\": [\"tests/Utils/Directives/GeocodeDirective.php\", \"tests/database/migrations/2026_06_30_000000_add_geocode_columns_to_users_table.php\", \"tests/Integration/Schema/Directives/CreateDirectiveTest.php\"], \"verifyCommand\": \"docker compose run --rm php vendor/bin/phpunit --filter=testGeocodePreSaveArgResolver\", \"acceptanceCriteria\": [\"@geocode implements SaveAwareArgResolver\", \"Takes AddressInput, sets lat/lng on model\", \"Integration test proves single-save persistence\"], \"modelTier\": \"standard\"}\n```" + }, + { + "id": 11, + "subject": "Task 7: Test SaveAwareArgResolver inside @nest (non-Model root)", + "status": "pending", + "blockedBy": [8], + "description": "**Goal:** Prove that a `SaveAwareArgResolver` inside a non-Model context still runs correctly (falls back to post-save position).\n\n**Files:**\n- Create: `tests/Unit/Execution/Arguments/Fixtures/SaveAwareNested.php`\n- Modify: `tests/Unit/Execution/Arguments/ArgPartitionerTest.php`\n\n**Acceptance Criteria:**\n- [ ] SaveAwareNested fixture created\n- [ ] Non-Model root: resolver in nested (post-save) set\n- [ ] Model root: resolver excluded from nested set\n- [ ] No crash\n\n**Verify:** `docker compose run --rm php vendor/bin/phpunit tests/Unit/Execution/Arguments/ArgPartitionerTest.php`\n\n```json:metadata\n{\"files\": [\"tests/Unit/Execution/Arguments/Fixtures/SaveAwareNested.php\", \"tests/Unit/Execution/Arguments/ArgPartitionerTest.php\"], \"verifyCommand\": \"docker compose run --rm php vendor/bin/phpunit tests/Unit/Execution/Arguments/ArgPartitionerTest.php\", \"acceptanceCriteria\": [\"SaveAwareNested fixture created\", \"Non-Model root: resolver in nested set\", \"Model root: resolver excluded from nested set\", \"No crash\"], \"modelTier\": \"mechanical\"}\n```" + }, + { + "id": 12, + "subject": "Task 8: Update CHANGELOG and run full test suite", + "status": "pending", + "blockedBy": [9, 10, 11], + "description": "**Goal:** Document the change and confirm nothing is broken.\n\n**Files:**\n- Modify: `CHANGELOG.md`\n\n**Acceptance Criteria:**\n- [ ] CHANGELOG entry under `## Unreleased` → `Added`\n- [ ] Full test suite passes\n- [ ] PHPStan passes\n- [ ] php-cs-fixer clean\n\n**Verify:** `docker compose run --rm php sh -c 'vendor/bin/phpstan && vendor/bin/phpunit'`\n\n```json:metadata\n{\"files\": [\"CHANGELOG.md\"], \"verifyCommand\": \"docker compose run --rm php sh -c 'vendor/bin/phpstan && vendor/bin/phpunit'\", \"acceptanceCriteria\": [\"CHANGELOG entry added\", \"Full test suite passes\", \"PHPStan passes\", \"php-cs-fixer clean\"], \"modelTier\": \"mechanical\"}\n```" + } + ], + "lastUpdated": "2026-06-30T12:00:00Z" +} diff --git a/docs/superpowers/specs/2026-06-30-save-aware-arg-resolver-design.md b/docs/superpowers/specs/2026-06-30-save-aware-arg-resolver-design.md index d5a8c7dc8c..f440848df7 100644 --- a/docs/superpowers/specs/2026-06-30-save-aware-arg-resolver-design.md +++ b/docs/superpowers/specs/2026-06-30-save-aware-arg-resolver-design.md @@ -2,8 +2,8 @@ ## Problem -`@upsert` (and `@create`/`@update`) on `INPUT_FIELD_DEFINITION` always runs post-save. -When the field targets a BelongsTo relation, it needs to run before the parent saves so the FK is available. +`@upsert`, `@create` and `@update` on `INPUT_FIELD_DEFINITION` always run post-save. +When the field targets a BelongsTo relation, they need to run before the parent saves so the FK is available. Custom directives that set model attributes from complex input (e.g. geocoding) also need pre-save timing without being relation-bound. ## Constraints @@ -21,7 +21,10 @@ namespace Nuwave\Lighthouse\Support\Contracts; use Illuminate\Database\Eloquent\Model; -/** @api */ +/** + * PHPDoc TBD describes when this interface may be needed + * @api + */ interface SaveAwareArgResolver extends ArgResolver { /** PHPDoc TBD — describes when the orchestrator calls this and what true/false means. */ @@ -30,7 +33,7 @@ interface SaveAwareArgResolver extends ArgResolver ``` - Extends `ArgResolver` -- a specialization, not a replacement. -- `@api` -- stability guarantee, consumers can implement this. +- `@api` -- stability guarantee, consumers can implement this. `ArgResolver` also gets `@api` - Receives the Model so the decision can be contextual (relation type introspection). - Replaces `PreSaveArgResolver` (branch-only, never shipped). @@ -56,7 +59,7 @@ public function runBeforeSave(Model $model): bool } ``` -BelongsTo/MorphTo returns true (pre-save), everything else returns false (post-save). +BelongsTo (MorphTo extends from it) returns true (pre-save), everything else returns false (post-save). ### Custom directives (e.g. @geocode test fixture) @@ -77,6 +80,7 @@ Extended to exclude `SaveAwareArgResolver` resolvers where `runBeforeSave()` ret Only checks when root is a Model -- non-Model contexts skip the check entirely: ```php +/** PHPDoc TBD clarify the implicit post-save default (explicit for SaveAware) */ public static function nestedArgResolvers(ArgumentSet $argumentSet, mixed $root): array { // ... attach resolvers (unchanged) ... @@ -88,11 +92,14 @@ public static function nestedArgResolvers(ArgumentSet $argumentSet, mixed $root) if ($resolver === null) { return false; } + if ($resolver instanceof SaveAwareArgResolver && $root instanceof Model - && $resolver->runBeforeSave($root)) { + && $resolver->runBeforeSave($root) + ) { return false; } + return true; }, ); @@ -104,7 +111,7 @@ public static function nestedArgResolvers(ArgumentSet $argumentSet, mixed $root) Extracts pre-save resolvers and runs them before `$model->save()`: ```php -[$preSave, $remaining] = ArgPartitioner::preSaveArgResolvers($remaining, $model); +[$preSave, $remaining] = ArgPartitioner::preSaveNestedArgResolvers($remaining, $model); // ... fill model, resolve implicit BelongsTo/MorphTo ... diff --git a/src/Execution/Arguments/ArgPartitioner.php b/src/Execution/Arguments/ArgPartitioner.php index 280b2b6b5e..5fc82bb892 100644 --- a/src/Execution/Arguments/ArgPartitioner.php +++ b/src/Execution/Arguments/ArgPartitioner.php @@ -11,20 +11,16 @@ use Illuminate\Database\Eloquent\Relations\MorphToMany; use Nuwave\Lighthouse\Exceptions\DefinitionException; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; -use Nuwave\Lighthouse\Support\Contracts\PreSaveArgResolver; use Nuwave\Lighthouse\Support\Utils; class ArgPartitioner { /** - * Partition the arguments into post-save and regular. + * Partition the arguments into nested and regular. * - * @return array{ - * 0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, - * 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, - * } + * @return array<\Nuwave\Lighthouse\Execution\Arguments\ArgumentSet> */ - public static function postSaveArgResolvers(ArgumentSet $argumentSet, mixed $root): array + public static function nestedArgResolvers(ArgumentSet $argumentSet, mixed $root): array { $model = $root instanceof Model ? new \ReflectionClass($root) @@ -36,28 +32,7 @@ public static function postSaveArgResolvers(ArgumentSet $argumentSet, mixed $roo return static::partition( $argumentSet, - static function (string $name, Argument $argument): bool { - $resolver = $argument->resolver; - - return $resolver !== null - && ! $resolver instanceof PreSaveArgResolver; - }, - ); - } - - /** - * Partition arguments into those with a pre-save resolver and the rest. - * - * @return array{ - * 0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, - * 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, - * } - */ - public static function preSaveArgResolvers(ArgumentSet $argumentSet): array - { - return static::partition( - $argumentSet, - static fn (string $name, Argument $argument): bool => $argument->resolver instanceof PreSaveArgResolver, + static fn (string $name, Argument $argument): bool => isset($argument->resolver), ); } @@ -83,10 +58,7 @@ public static function preSaveArgResolvers(ArgumentSet $argumentSet): array * ] * ] * - * @return array{ - * 0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, - * 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, - * } + * @return array{0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet} */ public static function relationMethods( ArgumentSet $argumentSet, @@ -171,10 +143,7 @@ protected static function attachNestedArgResolver(string $name, Argument &$argum * * @param callable(string $name, \Nuwave\Lighthouse\Execution\Arguments\Argument $argument): bool $predicate * - * @return array{ - * 0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, - * 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, - * } + * @return array{0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet} */ public static function partition(ArgumentSet $argumentSet, callable $predicate): array { diff --git a/src/Execution/Arguments/ResolveNested.php b/src/Execution/Arguments/ResolveNested.php index 4c2baf91e9..ed1890dcaf 100644 --- a/src/Execution/Arguments/ResolveNested.php +++ b/src/Execution/Arguments/ResolveNested.php @@ -16,7 +16,7 @@ class ResolveNested implements ArgResolver public function __construct(?callable $previous = null, ?callable $argPartitioner = null) { $this->previous = $previous; - $this->argPartitioner = $argPartitioner ?? [ArgPartitioner::class, 'postSaveArgResolvers']; + $this->argPartitioner = $argPartitioner ?? [ArgPartitioner::class, 'nestedArgResolvers']; } /** @param ArgumentSet $args */ @@ -30,9 +30,8 @@ public function __invoke(mixed $root, $args): mixed } foreach ($nestedArgs->arguments as $nested) { - $resolver = $nested->resolver; - assert($resolver !== null, 'Resolver must be set because we partitioned for it.'); - $resolver($root, $nested->value); + // @phpstan-ignore-next-line we know the resolver is there because we partitioned for it + ($nested->resolver)($root, $nested->value); } return $root; diff --git a/src/Execution/Arguments/SaveModel.php b/src/Execution/Arguments/SaveModel.php index cd3f5a0252..c80b634f94 100644 --- a/src/Execution/Arguments/SaveModel.php +++ b/src/Execution/Arguments/SaveModel.php @@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\Relation; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; -use Nuwave\Lighthouse\Support\Contracts\PreSaveArgResolver; class SaveModel implements ArgResolver { @@ -24,11 +23,9 @@ public function __construct( */ public function __invoke($model, $args): Model { - [$preSave, $remaining] = ArgPartitioner::preSaveArgResolvers($args); - // Extract $morphTo first, as MorphTo extends BelongsTo [$morphTo, $remaining] = ArgPartitioner::relationMethods( - $remaining, + $args, $model, MorphTo::class, ); @@ -62,12 +59,6 @@ public function __invoke($model, $args): Model $morphToResolver($model, $nestedOperations->value); } - foreach ($preSave->arguments as $nested) { - $resolver = $nested->resolver; - assert($resolver instanceof PreSaveArgResolver, 'Resolver must be a PreSaveArgResolver because we partitioned for it.'); - $resolver($model, $nested->value); - } - if ($this->parentRelation instanceof HasOneOrMany) { // If we are already resolving a nested create, we might // already have an instance of the parent relation available. diff --git a/src/Support/Contracts/ArgResolver.php b/src/Support/Contracts/ArgResolver.php index 8756f919ee..665fadc525 100644 --- a/src/Support/Contracts/ArgResolver.php +++ b/src/Support/Contracts/ArgResolver.php @@ -2,7 +2,6 @@ namespace Nuwave\Lighthouse\Support\Contracts; -/** @api */ interface ArgResolver { /** diff --git a/src/Support/Contracts/PreSaveArgResolver.php b/src/Support/Contracts/PreSaveArgResolver.php deleted file mode 100644 index c8d1e5a533..0000000000 --- a/src/Support/Contracts/PreSaveArgResolver.php +++ /dev/null @@ -1,11 +0,0 @@ -save(), - * allowing them to set foreign keys on the parent model (e.g. BelongsTo). - * - * @api - */ -interface PreSaveArgResolver extends ArgResolver {} diff --git a/tests/Integration/Schema/Directives/CreateDirectiveTest.php b/tests/Integration/Schema/Directives/CreateDirectiveTest.php index af6b2b7700..e50936dfc6 100644 --- a/tests/Integration/Schema/Directives/CreateDirectiveTest.php +++ b/tests/Integration/Schema/Directives/CreateDirectiveTest.php @@ -584,153 +584,4 @@ public function testTurnOnMassAssignment(): void } GRAPHQL); } - - public function testPreSaveArgResolverSetsForeignKeyBeforeSave(): void - { - $user = factory(User::class)->create(); - - $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' - type Task { - id: ID! - name: String! - user: User @belongsTo - } - - type User { - id: ID! - } - - type Mutation { - createTask(input: CreateTaskInput! @spread): Task @create - } - - input CreateTaskInput { - name: String! - owner: ID @connectRelated(relation: "user") - } - GRAPHQL; - - $this->graphQL(/** @lang GraphQL */ <<id}" - }) { - id - name - user { - id - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'createTask' => [ - 'id' => '1', - 'name' => 'My task', - 'user' => [ - 'id' => "{$user->id}", - ], - ], - ], - ]); - } - - public function testPreSaveArgResolverTakesPrecedenceOverImplicitRelationDetection(): void - { - $user = factory(User::class)->create(); - - $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' - type Task { - id: ID! - name: String! - user: User @belongsTo - } - - type User { - id: ID! - } - - type Mutation { - createTask(input: CreateTaskInput! @spread): Task @create - } - - input CreateTaskInput { - name: String! - user: ID @connectRelated - } - GRAPHQL; - - $this->graphQL(/** @lang GraphQL */ <<id}" - }) { - id - name - user { - id - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'createTask' => [ - 'id' => '1', - 'name' => 'My task', - 'user' => [ - 'id' => "{$user->id}", - ], - ], - ], - ]); - } - - public function testPreSaveArgResolverReceivesNullForDisassociation(): void - { - $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' - type Task { - id: ID! - name: String! - user: User @belongsTo - } - - type User { - id: ID! - } - - type Mutation { - createTask(input: CreateTaskInput! @spread): Task @create - } - - input CreateTaskInput { - name: String! - owner: ID @connectRelated(relation: "user") - } - GRAPHQL; - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - createTask(input: { - name: "My task" - owner: null - }) { - id - name - user { - id - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'createTask' => [ - 'id' => '1', - 'name' => 'My task', - 'user' => null, - ], - ], - ]); - } } diff --git a/tests/Unit/Execution/Arguments/ArgPartitionerTest.php b/tests/Unit/Execution/Arguments/ArgPartitionerTest.php index 793c302b9d..7c2f790f4a 100644 --- a/tests/Unit/Execution/Arguments/ArgPartitionerTest.php +++ b/tests/Unit/Execution/Arguments/ArgPartitionerTest.php @@ -9,7 +9,6 @@ use Nuwave\Lighthouse\Execution\Arguments\ArgumentSet; use Tests\TestCase; use Tests\Unit\Execution\Arguments\Fixtures\Nested; -use Tests\Unit\Execution\Arguments\Fixtures\PreNested; use Tests\Utils\Models\User; use Tests\Utils\Models\WithoutRelationClassImport; @@ -26,7 +25,7 @@ public function testPartitionArgsWithArgResolvers(): void $nested->directives->push(new Nested()); $argumentSet->arguments['nested'] = $nested; - [$nestedArgs, $regularArgs] = ArgPartitioner::postSaveArgResolvers($argumentSet, null); + [$nestedArgs, $regularArgs] = ArgPartitioner::nestedArgResolvers($argumentSet, null); $this->assertSame( ['regular' => $regular], @@ -39,41 +38,6 @@ public function testPartitionArgsWithArgResolvers(): void ); } - public function testPartitionPreSaveArgResolvers(): void - { - $argumentSet = new ArgumentSet(); - - $regular = new Argument(); - $argumentSet->arguments['regular'] = $regular; - - $postSave = new Argument(); - $postSave->directives->push(new Nested()); - $argumentSet->arguments['postSave'] = $postSave; - - $preSave = new Argument(); - $preSave->directives->push(new PreNested()); - $argumentSet->arguments['preSave'] = $preSave; - - [$postSaveArgs, $regularArgs] = ArgPartitioner::postSaveArgResolvers($argumentSet, null); - - $this->assertSame( - ['postSave' => $postSave], - $postSaveArgs->arguments, - ); - - [$preSaveArgs, $rest] = ArgPartitioner::preSaveArgResolvers($regularArgs); - - $this->assertSame( - ['preSave' => $preSave], - $preSaveArgs->arguments, - ); - - $this->assertSame( - ['regular' => $regular], - $rest->arguments, - ); - } - public function testPartitionArgsThatMatchRelationMethods(): void { $argumentSet = new ArgumentSet(); diff --git a/tests/Unit/Execution/Arguments/Fixtures/PreNested.php b/tests/Unit/Execution/Arguments/Fixtures/PreNested.php deleted file mode 100644 index f4cb799fa7..0000000000 --- a/tests/Unit/Execution/Arguments/Fixtures/PreNested.php +++ /dev/null @@ -1,18 +0,0 @@ -directiveArgValue('relation') - ?? $this->nodeName(); - $relation = $parent->{$relationName}(); - assert($relation instanceof BelongsTo); - - $relation->associate($id); - } -} From e421f7a0e3bdf26ec5989381656005b390421739 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 30 Jun 2026 09:08:51 +0200 Subject: [PATCH 19/47] Add SaveAwareArgResolver interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- src/Support/Contracts/ArgResolver.php | 1 + .../Contracts/SaveAwareArgResolver.php | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 src/Support/Contracts/SaveAwareArgResolver.php diff --git a/src/Support/Contracts/ArgResolver.php b/src/Support/Contracts/ArgResolver.php index 665fadc525..8756f919ee 100644 --- a/src/Support/Contracts/ArgResolver.php +++ b/src/Support/Contracts/ArgResolver.php @@ -2,6 +2,7 @@ namespace Nuwave\Lighthouse\Support\Contracts; +/** @api */ interface ArgResolver { /** diff --git a/src/Support/Contracts/SaveAwareArgResolver.php b/src/Support/Contracts/SaveAwareArgResolver.php new file mode 100644 index 0000000000..76b04ffe92 --- /dev/null +++ b/src/Support/Contracts/SaveAwareArgResolver.php @@ -0,0 +1,28 @@ +save(), allowing it + * to set attributes or foreign keys on the model. + * When false, the resolver runs after the model is saved (the default + * for any ArgResolver that does not implement this interface). + * + * Only consulted when the root is a Model inside SaveModel's orchestration. + * In non-Model contexts (e.g. @nest), this method is not called and the + * resolver runs in the default post-save position. + */ + public function runBeforeSave(Model $model): bool; +} From 12d95dfeb23bc96117d19f46f10a64d40a5e77ae Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 30 Jun 2026 09:09:29 +0200 Subject: [PATCH 20/47] Implement SaveAwareArgResolver in ModelMutationDirective MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts relationName() and uses it to dynamically decide timing: BelongsTo/MorphTo → pre-save, everything else → post-save. 🤖 Generated with Claude Code --- .../Directives/ModelMutationDirective.php | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/Schema/Directives/ModelMutationDirective.php b/src/Schema/Directives/ModelMutationDirective.php index 983632b726..75a7cd9165 100644 --- a/src/Schema/Directives/ModelMutationDirective.php +++ b/src/Schema/Directives/ModelMutationDirective.php @@ -3,20 +3,39 @@ namespace Nuwave\Lighthouse\Schema\Directives; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\Relation; +use Nuwave\Lighthouse\Execution\Arguments\ArgPartitioner; use Nuwave\Lighthouse\Execution\Arguments\ArgumentSet; use Nuwave\Lighthouse\Execution\Arguments\ResolveNested; use Nuwave\Lighthouse\Execution\TransactionalMutations; -use Nuwave\Lighthouse\Support\Contracts\ArgResolver; use Nuwave\Lighthouse\Support\Contracts\FieldResolver; +use Nuwave\Lighthouse\Support\Contracts\SaveAwareArgResolver; use Nuwave\Lighthouse\Support\Utils; -abstract class ModelMutationDirective extends BaseDirective implements FieldResolver, ArgResolver +abstract class ModelMutationDirective extends BaseDirective implements FieldResolver, SaveAwareArgResolver { public function __construct( protected TransactionalMutations $transactionalMutations, ) {} + protected function relationName(): string + { + return $this->directiveArgValue( + 'relation', + $this->nodeName(), + ); + } + + public function runBeforeSave(Model $model): bool + { + return ArgPartitioner::methodReturnsRelation( + new \ReflectionClass($model), + $this->relationName(), + BelongsTo::class, + ); + } + /** * @param Model $model * @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet|array<\Nuwave\Lighthouse\Execution\Arguments\ArgumentSet> $args @@ -25,13 +44,7 @@ public function __construct( */ public function __invoke($model, $args): mixed { - $relationName = $this->directiveArgValue( - 'relation', - // Use the name of the argument if no explicit relation name is given - $this->nodeName(), - ); - - $relation = $model->{$relationName}(); + $relation = $model->{$this->relationName()}(); assert($relation instanceof Relation); $related = $relation->make(); // @phpstan-ignore method.notFound (Relation delegates to Builder) From 69961fbc54c7a8aa80b6e864c3d6b7cda407e863 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 30 Jun 2026 09:10:31 +0200 Subject: [PATCH 21/47] Route SaveAwareArgResolver through pre-save in SaveModel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nestedArgResolvers now excludes resolvers where runBeforeSave() is true, passing them through to SaveModel which invokes them before $model->save(). 🤖 Generated with Claude Code --- src/Execution/Arguments/ArgPartitioner.php | 45 ++++++++++++++++++++-- src/Execution/Arguments/SaveModel.php | 11 +++++- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/Execution/Arguments/ArgPartitioner.php b/src/Execution/Arguments/ArgPartitioner.php index 5fc82bb892..325295dd82 100644 --- a/src/Execution/Arguments/ArgPartitioner.php +++ b/src/Execution/Arguments/ArgPartitioner.php @@ -11,14 +11,22 @@ use Illuminate\Database\Eloquent\Relations\MorphToMany; use Nuwave\Lighthouse\Exceptions\DefinitionException; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; +use Nuwave\Lighthouse\Support\Contracts\SaveAwareArgResolver; use Nuwave\Lighthouse\Support\Utils; class ArgPartitioner { /** - * Partition the arguments into nested and regular. + * Partition the arguments into nested (post-save) and regular. * - * @return array<\Nuwave\Lighthouse\Execution\Arguments\ArgumentSet> + * Resolvers implementing SaveAwareArgResolver that return true from + * runBeforeSave() are excluded from the nested set when the root is a Model, + * allowing SaveModel to handle them before persisting. + * + * @return array{ + * 0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, + * 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, + * } */ public static function nestedArgResolvers(ArgumentSet $argumentSet, mixed $root): array { @@ -32,7 +40,38 @@ public static function nestedArgResolvers(ArgumentSet $argumentSet, mixed $root) return static::partition( $argumentSet, - static fn (string $name, Argument $argument): bool => isset($argument->resolver), + static function (string $name, Argument $argument) use ($root): bool { + $resolver = $argument->resolver; + if ($resolver === null) { + return false; + } + + if ($resolver instanceof SaveAwareArgResolver + && $root instanceof Model + && $resolver->runBeforeSave($root) + ) { + return false; + } + + return true; + }, + ); + } + + /** + * Partition arguments into those with a pre-save resolver and the rest. + * + * @return array{ + * 0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, + * 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, + * } + */ + public static function preSaveNestedArgResolvers(ArgumentSet $argumentSet, Model $model): array + { + return static::partition( + $argumentSet, + static fn (string $name, Argument $argument): bool => $argument->resolver instanceof SaveAwareArgResolver + && $argument->resolver->runBeforeSave($model), ); } diff --git a/src/Execution/Arguments/SaveModel.php b/src/Execution/Arguments/SaveModel.php index c80b634f94..4222aebaf0 100644 --- a/src/Execution/Arguments/SaveModel.php +++ b/src/Execution/Arguments/SaveModel.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\Relation; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; +use Nuwave\Lighthouse\Support\Contracts\SaveAwareArgResolver; class SaveModel implements ArgResolver { @@ -23,9 +24,11 @@ public function __construct( */ public function __invoke($model, $args): Model { + [$preSave, $remaining] = ArgPartitioner::preSaveNestedArgResolvers($args, $model); + // Extract $morphTo first, as MorphTo extends BelongsTo [$morphTo, $remaining] = ArgPartitioner::relationMethods( - $args, + $remaining, $model, MorphTo::class, ); @@ -59,6 +62,12 @@ public function __invoke($model, $args): Model $morphToResolver($model, $nestedOperations->value); } + foreach ($preSave->arguments as $nested) { + $resolver = $nested->resolver; + assert($resolver instanceof SaveAwareArgResolver, 'Resolver must be a SaveAwareArgResolver because we partitioned for it.'); + $resolver($model, $nested->value); + } + if ($this->parentRelation instanceof HasOneOrMany) { // If we are already resolving a nested create, we might // already have an instance of the parent relation available. From e5e32f79dd2f4b88db75cff16f8f479837117986 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 30 Jun 2026 09:11:09 +0200 Subject: [PATCH 22/47] Test @upsert on BelongsTo INPUT_FIELD_DEFINITION MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- .../Schema/Directives/CreateDirectiveTest.php | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/tests/Integration/Schema/Directives/CreateDirectiveTest.php b/tests/Integration/Schema/Directives/CreateDirectiveTest.php index e50936dfc6..2d68dc82e9 100644 --- a/tests/Integration/Schema/Directives/CreateDirectiveTest.php +++ b/tests/Integration/Schema/Directives/CreateDirectiveTest.php @@ -584,4 +584,118 @@ public function testTurnOnMassAssignment(): void } GRAPHQL); } + + public function testUpsertBelongsToBeforeSave(): void + { + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Task { + id: ID! + name: String! + user: User @belongsTo + } + + type User { + id: ID! + name: String! + } + + type Mutation { + createTask(input: CreateTaskInput! @spread): Task @create + } + + input CreateTaskInput { + name: String! + user: CreateUserInput @upsert + } + + input CreateUserInput { + id: ID + name: String! + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createTask(input: { + name: "My task" + user: { + name: "New User" + } + }) { + id + name + user { + id + name + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createTask' => [ + 'name' => 'My task', + 'user' => [ + 'id' => '1', + 'name' => 'New User', + ], + ], + ], + ]); + } + + public function testUpsertBelongsToTakesPrecedenceOverImplicitRelation(): void + { + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Task { + id: ID! + name: String! + user: User @belongsTo + } + + type User { + id: ID! + name: String! + } + + type Mutation { + createTask(input: CreateTaskInput! @spread): Task @create + } + + input CreateTaskInput { + name: String! + user: CreateUserInput @upsert + } + + input CreateUserInput { + id: ID + name: String! + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createTask(input: { + name: "My task" + user: { + name: "Created via directive" + } + }) { + id + name + user { + name + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createTask' => [ + 'name' => 'My task', + 'user' => [ + 'name' => 'Created via directive', + ], + ], + ], + ]); + } } From 4e3055433fd4fac76430b73872d5cd331f628504 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 30 Jun 2026 09:11:55 +0200 Subject: [PATCH 23/47] Test @geocode non-relation SaveAwareArgResolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- .../Schema/Directives/CreateDirectiveTest.php | 51 +++++++++++++++++++ tests/Utils/Directives/GeocodeDirective.php | 34 +++++++++++++ ...000_add_geocode_columns_to_users_table.php | 22 ++++++++ 3 files changed, 107 insertions(+) create mode 100644 tests/Utils/Directives/GeocodeDirective.php create mode 100644 tests/database/migrations/2026_06_30_000000_add_geocode_columns_to_users_table.php diff --git a/tests/Integration/Schema/Directives/CreateDirectiveTest.php b/tests/Integration/Schema/Directives/CreateDirectiveTest.php index 2d68dc82e9..70d6679103 100644 --- a/tests/Integration/Schema/Directives/CreateDirectiveTest.php +++ b/tests/Integration/Schema/Directives/CreateDirectiveTest.php @@ -698,4 +698,55 @@ public function testUpsertBelongsToTakesPrecedenceOverImplicitRelation(): void ], ]); } + + public function testGeocodePreSaveArgResolver(): void + { + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + name: String! + latitude: Float + longitude: Float + } + + type Mutation { + createUser(input: CreateUserInput! @spread): User @create + } + + input CreateUserInput { + name: String! + location: LocationInput @geocode + } + + input LocationInput { + lat: Float! + lng: Float! + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createUser(input: { + name: "Geo User" + location: { + lat: 48.1351 + lng: 11.5820 + } + }) { + id + name + latitude + longitude + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createUser' => [ + 'name' => 'Geo User', + 'latitude' => 48.1351, + 'longitude' => 11.582, + ], + ], + ]); + } } diff --git a/tests/Utils/Directives/GeocodeDirective.php b/tests/Utils/Directives/GeocodeDirective.php new file mode 100644 index 0000000000..811886a85e --- /dev/null +++ b/tests/Utils/Directives/GeocodeDirective.php @@ -0,0 +1,34 @@ +toArray(); + $model->setAttribute('latitude', $address['lat'] ?? 0.0); + $model->setAttribute('longitude', $address['lng'] ?? 0.0); + } +} diff --git a/tests/database/migrations/2026_06_30_000000_add_geocode_columns_to_users_table.php b/tests/database/migrations/2026_06_30_000000_add_geocode_columns_to_users_table.php new file mode 100644 index 0000000000..7ae51001f4 --- /dev/null +++ b/tests/database/migrations/2026_06_30_000000_add_geocode_columns_to_users_table.php @@ -0,0 +1,22 @@ +float('latitude')->nullable(); + $table->float('longitude')->nullable(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn('latitude', 'longitude'); + }); + } +}; From 869e25d67a3e235ad9c0358e1405f7facc3cde97 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 30 Jun 2026 09:12:31 +0200 Subject: [PATCH 24/47] Test SaveAwareArgResolver partitioning with Model and non-Model roots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- .../Arguments/ArgPartitionerTest.php | 51 +++++++++++++++++++ .../Arguments/Fixtures/SaveAwareNested.php | 24 +++++++++ 2 files changed, 75 insertions(+) create mode 100644 tests/Unit/Execution/Arguments/Fixtures/SaveAwareNested.php diff --git a/tests/Unit/Execution/Arguments/ArgPartitionerTest.php b/tests/Unit/Execution/Arguments/ArgPartitionerTest.php index 7c2f790f4a..0854df2ce6 100644 --- a/tests/Unit/Execution/Arguments/ArgPartitionerTest.php +++ b/tests/Unit/Execution/Arguments/ArgPartitionerTest.php @@ -9,6 +9,7 @@ use Nuwave\Lighthouse\Execution\Arguments\ArgumentSet; use Tests\TestCase; use Tests\Unit\Execution\Arguments\Fixtures\Nested; +use Tests\Unit\Execution\Arguments\Fixtures\SaveAwareNested; use Tests\Utils\Models\User; use Tests\Utils\Models\WithoutRelationClassImport; @@ -110,4 +111,54 @@ public function testPartitionArgsExceptionBadRelationType(): void HasMany::class, ); } + + public function testSaveAwareArgResolverWithNonModelRoot(): void + { + $argumentSet = new ArgumentSet(); + + $regular = new Argument(); + $argumentSet->arguments['regular'] = $regular; + + $saveAware = new Argument(); + $saveAware->directives->push(new SaveAwareNested()); + $argumentSet->arguments['saveAware'] = $saveAware; + + [$nestedArgs, $regularArgs] = ArgPartitioner::nestedArgResolvers($argumentSet, null); + + $this->assertSame( + ['regular' => $regular], + $regularArgs->arguments, + ); + + $this->assertSame( + ['saveAware' => $saveAware], + $nestedArgs->arguments, + 'SaveAwareArgResolver should be in nested (post-save) set when root is not a Model', + ); + } + + public function testSaveAwareArgResolverWithModelRoot(): void + { + $argumentSet = new ArgumentSet(); + + $regular = new Argument(); + $argumentSet->arguments['regular'] = $regular; + + $saveAware = new Argument(); + $saveAware->directives->push(new SaveAwareNested()); + $argumentSet->arguments['saveAware'] = $saveAware; + + [$nestedArgs, $regularArgs] = ArgPartitioner::nestedArgResolvers($argumentSet, new User()); + + $this->assertSame( + ['regular' => $regular, 'saveAware' => $saveAware], + $regularArgs->arguments, + 'SaveAwareArgResolver with runBeforeSave=true should be excluded from nested set when root is Model', + ); + + $this->assertSame( + [], + $nestedArgs->arguments, + ); + } } diff --git a/tests/Unit/Execution/Arguments/Fixtures/SaveAwareNested.php b/tests/Unit/Execution/Arguments/Fixtures/SaveAwareNested.php new file mode 100644 index 0000000000..ab3475c2c3 --- /dev/null +++ b/tests/Unit/Execution/Arguments/Fixtures/SaveAwareNested.php @@ -0,0 +1,24 @@ + Date: Tue, 30 Jun 2026 09:17:09 +0200 Subject: [PATCH 25/47] Update CHANGELOG and apply php-cs-fixer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- CHANGELOG.md | 2 +- src/Support/Contracts/ArgResolver.php | 4 +++- .../2026_06_30_000000_add_geocode_columns_to_users_table.php | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b31283eec0..c83d84ea03 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 `PreSaveArgResolver` interface for custom directives that resolve before `$model->save()` https://github.com/nuwave/lighthouse/pull/2777 +- Add `SaveAwareArgResolver` interface for directives that need control over pre/post-save timing in mutations https://github.com/nuwave/lighthouse/pull/2777 ## v6.67.0 diff --git a/src/Support/Contracts/ArgResolver.php b/src/Support/Contracts/ArgResolver.php index 8756f919ee..4c512263ce 100644 --- a/src/Support/Contracts/ArgResolver.php +++ b/src/Support/Contracts/ArgResolver.php @@ -2,7 +2,9 @@ namespace Nuwave\Lighthouse\Support\Contracts; -/** @api */ +/** + * @api + */ interface ArgResolver { /** diff --git a/tests/database/migrations/2026_06_30_000000_add_geocode_columns_to_users_table.php b/tests/database/migrations/2026_06_30_000000_add_geocode_columns_to_users_table.php index 7ae51001f4..40e3364af8 100644 --- a/tests/database/migrations/2026_06_30_000000_add_geocode_columns_to_users_table.php +++ b/tests/database/migrations/2026_06_30_000000_add_geocode_columns_to_users_table.php @@ -4,7 +4,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class() extends Migration { +return new class extends Migration { public function up(): void { Schema::table('users', function (Blueprint $table): void { From ee7b581a732b54bad8662696f08f8953097e4ef3 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 30 Jun 2026 09:18:00 +0200 Subject: [PATCH 26/47] add settings --- .ai/settings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.ai/settings.json b/.ai/settings.json index ba6ec04588..50d82ae482 100644 --- a/.ai/settings.json +++ b/.ai/settings.json @@ -2,6 +2,9 @@ "$schema": "https://lnai.sh/schemas/settings.schema.json", "permissions": { "allow": [ + "Bash(docker compose run --rm php vendor/bin/phpstan:*)", + "Bash(docker compose run --rm php vendor/bin/phpunit:*)", + "Bash(docker compose run --rm php vendor/bin/php-cs-fixer:*)", "Bash(docker compose up:*)", "Bash(make setup)", "Bash(make it)", From d469b61ca41f21f4caa84b018dafc82a45d86762 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 30 Jun 2026 10:27:16 +0200 Subject: [PATCH 27/47] Address self-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Handle null args in ModelMutationDirective::__invoke (prevents crash for nullable @upsert fields) - Use non-nullable FK (Post→Task) in testUpsertBelongsToBeforeSave to actually falsify pre-save ordering - Differentiate testUpsertBelongsToTakesPrecedenceOverImplicitRelation by verifying the directive updates an existing model - Add unit tests: null values pass through preSaveNestedArgResolvers, SaveAwareArgResolver executes with non-Model root - Add integration test for @upsert BelongsTo with null value - Fix one-sentence-per-line violations, replace what-comment with precondition comment - Remove unreachable ?? 0.0 fallbacks in GeocodeDirective fixture - Remove docs/superpowers/ planning artifacts 🤖 Generated with Claude Code --- .../2026-06-30-save-aware-arg-resolver.md | 963 ------------------ ...6-30-save-aware-arg-resolver.md.tasks.json | 61 -- ...26-06-30-save-aware-arg-resolver-design.md | 172 ---- src/Execution/Arguments/ArgPartitioner.php | 15 +- .../Directives/ModelMutationDirective.php | 8 +- .../Contracts/SaveAwareArgResolver.php | 3 +- .../Schema/Directives/CreateDirectiveTest.php | 114 ++- .../Arguments/ArgPartitionerTest.php | 43 + .../Arguments/Fixtures/SaveAwareNested.php | 10 +- tests/Utils/Directives/GeocodeDirective.php | 4 +- 10 files changed, 152 insertions(+), 1241 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-30-save-aware-arg-resolver.md delete mode 100644 docs/superpowers/plans/2026-06-30-save-aware-arg-resolver.md.tasks.json delete mode 100644 docs/superpowers/specs/2026-06-30-save-aware-arg-resolver-design.md diff --git a/docs/superpowers/plans/2026-06-30-save-aware-arg-resolver.md b/docs/superpowers/plans/2026-06-30-save-aware-arg-resolver.md deleted file mode 100644 index cf914be7f3..0000000000 --- a/docs/superpowers/plans/2026-06-30-save-aware-arg-resolver.md +++ /dev/null @@ -1,963 +0,0 @@ -# SaveAwareArgResolver Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the branch-only `PreSaveArgResolver` with `SaveAwareArgResolver` — an interface that lets directives dynamically decide pre/post-save timing based on the Model context. - -**Architecture:** `SaveAwareArgResolver extends ArgResolver` with a single `runBeforeSave(Model $model): bool` method. `ArgPartitioner::nestedArgResolvers()` excludes resolvers that return true (passing them through to `SaveModel`). `SaveModel` extracts and invokes them before `$model->save()`. `ModelMutationDirective` implements the interface using relation-type introspection. - -**Tech Stack:** PHP 8.2, Laravel/Eloquent, PHPUnit, PHPStan level 8 - -**User decisions (already made):** -- "Keep `nestedArgResolvers` name — avoid the rename to `postSaveArgResolvers`" -- "Interface should be `SaveAwareArgResolver` with `runBeforeSave(Model $model): bool`" -- "Use `@geocode` test fixture for non-relation pre-save case" -- "Relation-name logic extracted into `relationName()` method on ModelMutationDirective" - ---- - -### Task 1: Reset branch to master baseline for affected files - -**Goal:** Remove all branch-only `PreSaveArgResolver` code so we start clean from master for the new design. - -**Files:** -- Delete: `src/Support/Contracts/PreSaveArgResolver.php` -- Delete: `tests/Utils/Directives/ConnectRelatedDirective.php` -- Delete: `tests/Unit/Execution/Arguments/Fixtures/PreNested.php` -- Revert: `src/Execution/Arguments/ArgPartitioner.php` (to master) -- Revert: `src/Execution/Arguments/ResolveNested.php` (to master) -- Revert: `src/Execution/Arguments/SaveModel.php` (to master) -- Revert: `src/Support/Contracts/ArgResolver.php` (to master) -- Revert: `tests/Unit/Execution/Arguments/ArgPartitionerTest.php` (to master) -- Revert: `tests/Integration/Schema/Directives/CreateDirectiveTest.php` (to master) - -**Acceptance Criteria:** -- [ ] `PreSaveArgResolver.php` no longer exists -- [ ] `ConnectRelatedDirective.php` no longer exists -- [ ] `PreNested.php` no longer exists -- [ ] `ArgPartitioner.php`, `ResolveNested.php`, `SaveModel.php` match master -- [ ] `ArgResolver.php` matches master (no `@api` yet — added in Task 2) -- [ ] Tests pass: `vendor/bin/phpunit tests/Unit/Execution/Arguments/ArgPartitionerTest.php` - -**Verify:** `docker compose run --rm php vendor/bin/phpunit tests/Unit/Execution/Arguments/ArgPartitionerTest.php` → PASS - -**Steps:** - -- [ ] **Step 1: Revert src files to master** - -```bash -git -C /home/bfranke/projects/lighthouse checkout master -- \ - src/Execution/Arguments/ArgPartitioner.php \ - src/Execution/Arguments/ResolveNested.php \ - src/Execution/Arguments/SaveModel.php \ - src/Support/Contracts/ArgResolver.php \ - tests/Unit/Execution/Arguments/ArgPartitionerTest.php \ - tests/Integration/Schema/Directives/CreateDirectiveTest.php -``` - -- [ ] **Step 2: Delete branch-only files** - -```bash -git -C /home/bfranke/projects/lighthouse rm \ - src/Support/Contracts/PreSaveArgResolver.php \ - tests/Utils/Directives/ConnectRelatedDirective.php \ - tests/Unit/Execution/Arguments/Fixtures/PreNested.php -``` - -- [ ] **Step 3: Run tests to verify clean state** - -Run: `docker compose run --rm php vendor/bin/phpunit tests/Unit/Execution/Arguments/ArgPartitionerTest.php` -Expected: PASS - -- [ ] **Step 4: Commit** - -```bash -git -C /home/bfranke/projects/lighthouse add -A && git -C /home/bfranke/projects/lighthouse commit -m "Remove PreSaveArgResolver in preparation for SaveAwareArgResolver" -``` - ---- - -### Task 2: Create SaveAwareArgResolver interface - -**Goal:** Define the new interface with `@api` stability guarantee. - -**Files:** -- Create: `src/Support/Contracts/SaveAwareArgResolver.php` -- Modify: `src/Support/Contracts/ArgResolver.php` (add `@api`) - -**Acceptance Criteria:** -- [ ] Interface extends `ArgResolver` -- [ ] Has `runBeforeSave(Model $model): bool` method -- [ ] Both `ArgResolver` and `SaveAwareArgResolver` have `@api` annotation -- [ ] PHPStan passes - -**Verify:** `docker compose run --rm php vendor/bin/phpstan analyse src/Support/Contracts/SaveAwareArgResolver.php src/Support/Contracts/ArgResolver.php` → OK - -**Steps:** - -- [ ] **Step 1: Add `@api` to ArgResolver** - -In `src/Support/Contracts/ArgResolver.php`, add `@api` to the class docblock: - -```php - $value the slice of arguments that belongs to this nested resolver - * - * @return mixed|void|null May return the modified $root - */ - public function __invoke(mixed $root, mixed $value); -} -``` - -- [ ] **Step 2: Create SaveAwareArgResolver** - -Create `src/Support/Contracts/SaveAwareArgResolver.php`: - -```php -save(), allowing it - * to set attributes or foreign keys on the model. - * When false, the resolver runs after the model is saved (the default - * for any ArgResolver that does not implement this interface). - * - * Only consulted when the root is a Model inside SaveModel's orchestration. - * In non-Model contexts (e.g. @nest), this method is not called and the - * resolver runs in the default post-save position. - */ - public function runBeforeSave(Model $model): bool; -} -``` - -- [ ] **Step 3: Run PHPStan** - -Run: `docker compose run --rm php vendor/bin/phpstan analyse src/Support/Contracts/` -Expected: OK (no errors) - -- [ ] **Step 4: Commit** - -```bash -git -C /home/bfranke/projects/lighthouse add src/Support/Contracts/SaveAwareArgResolver.php src/Support/Contracts/ArgResolver.php && git -C /home/bfranke/projects/lighthouse commit -m "Add SaveAwareArgResolver interface" -``` - ---- - -### Task 3: Implement SaveAwareArgResolver in ModelMutationDirective - -**Goal:** Make `@upsert`, `@create`, and `@update` dynamically decide pre/post-save timing based on relation type. - -**Files:** -- Modify: `src/Schema/Directives/ModelMutationDirective.php` - -**Acceptance Criteria:** -- [ ] `ModelMutationDirective` implements `SaveAwareArgResolver` -- [ ] New `relationName(): string` method extracted from `__invoke()` -- [ ] `runBeforeSave()` returns true for BelongsTo relations (which includes MorphTo) -- [ ] `__invoke()` uses `$this->relationName()` instead of inline logic -- [ ] PHPStan passes - -**Verify:** `docker compose run --rm php vendor/bin/phpstan analyse src/Schema/Directives/ModelMutationDirective.php` → OK - -**Steps:** - -- [ ] **Step 1: Add interface and implement** - -Modify `src/Schema/Directives/ModelMutationDirective.php`: - -```php -directiveArgValue( - 'relation', - $this->nodeName(), - ); - } - - public function runBeforeSave(Model $model): bool - { - return ArgPartitioner::methodReturnsRelation( - new \ReflectionClass($model), - $this->relationName(), - BelongsTo::class, - ); - } - - /** - * @param Model $model - * @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet|array<\Nuwave\Lighthouse\Execution\Arguments\ArgumentSet> $args - * - * @return \Illuminate\Database\Eloquent\Model|array<\Illuminate\Database\Eloquent\Model> - */ - public function __invoke($model, $args): mixed - { - $relation = $model->{$this->relationName()}(); - assert($relation instanceof Relation); - - $related = $relation->make(); // @phpstan-ignore method.notFound (Relation delegates to Builder) - - return $this->executeMutation($related, $args, $relation); - } - - /** - * @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet|array<\Nuwave\Lighthouse\Execution\Arguments\ArgumentSet> $args - * @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null $parentRelation - * - * @return \Illuminate\Database\Eloquent\Model|array<\Illuminate\Database\Eloquent\Model> - */ - protected function executeMutation(Model $model, ArgumentSet|array $args, ?Relation $parentRelation = null): Model|array - { - $update = new ResolveNested($this->makeExecutionFunction($parentRelation)); - - return Utils::mapEach( - static fn (ArgumentSet $argumentSet): mixed => $update($model->newInstance(), $argumentSet), - $args, - ); - } - - /** - * Prepare the execution function for a mutation on a model. - * - * @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null $parentRelation - */ - abstract protected function makeExecutionFunction(?Relation $parentRelation = null): callable; -} -``` - -- [ ] **Step 2: Run PHPStan** - -Run: `docker compose run --rm php vendor/bin/phpstan analyse src/Schema/Directives/ModelMutationDirective.php` -Expected: OK - -- [ ] **Step 3: Commit** - -```bash -git -C /home/bfranke/projects/lighthouse add src/Schema/Directives/ModelMutationDirective.php && git -C /home/bfranke/projects/lighthouse commit -m "Implement SaveAwareArgResolver in ModelMutationDirective" -``` - ---- - -### Task 4: Update ArgPartitioner and SaveModel orchestration - -**Goal:** Make `nestedArgResolvers` exclude pre-save resolvers (passing them to SaveModel), and make SaveModel extract and invoke them before save. - -**Files:** -- Modify: `src/Execution/Arguments/ArgPartitioner.php` -- Modify: `src/Execution/Arguments/SaveModel.php` - -**Acceptance Criteria:** -- [ ] `nestedArgResolvers` excludes `SaveAwareArgResolver` resolvers where `runBeforeSave($root)` is true (only when root is Model) -- [ ] New `preSaveNestedArgResolvers(ArgumentSet, Model): array` method on ArgPartitioner -- [ ] `SaveModel` extracts pre-save resolvers before implicit BelongsTo detection -- [ ] `SaveModel` invokes pre-save resolvers before `$model->save()` -- [ ] PHPStan passes for both files - -**Verify:** `docker compose run --rm php vendor/bin/phpstan analyse src/Execution/Arguments/ArgPartitioner.php src/Execution/Arguments/SaveModel.php` → OK - -**Steps:** - -- [ ] **Step 1: Update ArgPartitioner::nestedArgResolvers** - -In `src/Execution/Arguments/ArgPartitioner.php`, modify the `nestedArgResolvers` method and add `preSaveNestedArgResolvers`: - -```php -use Nuwave\Lighthouse\Support\Contracts\SaveAwareArgResolver; -``` - -Replace the `nestedArgResolvers` method: - -```php - /** - * Partition the arguments into nested (post-save) and regular. - * - * Resolvers implementing SaveAwareArgResolver that return true from - * runBeforeSave() are excluded from the nested set when the root is a Model, - * allowing SaveModel to handle them before persisting. - * - * @return array{ - * 0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, - * 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, - * } - */ - public static function nestedArgResolvers(ArgumentSet $argumentSet, mixed $root): array - { - $model = $root instanceof Model - ? new \ReflectionClass($root) - : null; - - foreach ($argumentSet->arguments as $name => $argument) { - static::attachNestedArgResolver($name, $argument, $model); - } - - return static::partition( - $argumentSet, - static function (string $name, Argument $argument) use ($root): bool { - $resolver = $argument->resolver; - if ($resolver === null) { - return false; - } - - if ($resolver instanceof SaveAwareArgResolver - && $root instanceof Model - && $resolver->runBeforeSave($root) - ) { - return false; - } - - return true; - }, - ); - } - - /** - * Partition arguments into those with a pre-save resolver and the rest. - * - * @return array{ - * 0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, - * 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, - * } - */ - public static function preSaveNestedArgResolvers(ArgumentSet $argumentSet, Model $model): array - { - return static::partition( - $argumentSet, - static fn (string $name, Argument $argument): bool => $argument->resolver instanceof SaveAwareArgResolver - && $argument->resolver->runBeforeSave($model), - ); - } -``` - -- [ ] **Step 2: Update SaveModel** - -In `src/Execution/Arguments/SaveModel.php`, add the import and pre-save logic: - -```php -use Nuwave\Lighthouse\Support\Contracts\SaveAwareArgResolver; -``` - -Replace the `__invoke` method body: - -```php - /** - * @param Model $model - * @param ArgumentSet $args - */ - public function __invoke($model, $args): Model - { - [$preSave, $remaining] = ArgPartitioner::preSaveNestedArgResolvers($args, $model); - - // Extract $morphTo first, as MorphTo extends BelongsTo - [$morphTo, $remaining] = ArgPartitioner::relationMethods( - $remaining, - $model, - MorphTo::class, - ); - - [$belongsTo, $remaining] = ArgPartitioner::relationMethods( - $remaining, - $model, - BelongsTo::class, - ); - - $argsToFill = $remaining->toArray(); - - // Use all the remaining attributes and fill the model - if (config('lighthouse.force_fill')) { - $model->forceFill($argsToFill); - } else { - $model->fill($argsToFill); - } - - foreach ($belongsTo->arguments as $relationName => $nestedOperations) { - $belongsTo = $model->{$relationName}(); - assert($belongsTo instanceof BelongsTo); - $belongsToResolver = new ResolveNested(new NestedBelongsTo($belongsTo)); - $belongsToResolver($model, $nestedOperations->value); - } - - foreach ($morphTo->arguments as $relationName => $nestedOperations) { - $morphTo = $model->{$relationName}(); - assert($morphTo instanceof MorphTo); - $morphToResolver = new ResolveNested(new NestedMorphTo($morphTo)); - $morphToResolver($model, $nestedOperations->value); - } - - foreach ($preSave->arguments as $nested) { - $resolver = $nested->resolver; - assert($resolver instanceof SaveAwareArgResolver, 'Resolver must be a SaveAwareArgResolver because we partitioned for it.'); - $resolver($model, $nested->value); - } - - if ($this->parentRelation instanceof HasOneOrMany) { - // If we are already resolving a nested create, we might - // already have an instance of the parent relation available. - // In that case, use it to set the current model as a child. - $this->parentRelation->save($model); - - return $model; - } - - $model->save(); - - if ($this->parentRelation instanceof BelongsTo) { - $childModel = $this->parentRelation->associate($model); - - // If the child Model does not exist (still to be saved), - // a save could break any pending belongsTo relations that still - // need to be created and associated with it. - if ($childModel->exists) { - $childModel->save(); - } - } - - if ($this->parentRelation instanceof BelongsToMany) { - $this->parentRelation->syncWithoutDetaching($model); - } - - return $model; - } -``` - -- [ ] **Step 3: Run PHPStan** - -Run: `docker compose run --rm php vendor/bin/phpstan analyse src/Execution/Arguments/ArgPartitioner.php src/Execution/Arguments/SaveModel.php` -Expected: OK - -- [ ] **Step 4: Commit** - -```bash -git -C /home/bfranke/projects/lighthouse add src/Execution/Arguments/ArgPartitioner.php src/Execution/Arguments/SaveModel.php && git -C /home/bfranke/projects/lighthouse commit -m "Route SaveAwareArgResolver through pre-save in SaveModel" -``` - ---- - -### Task 5: Test @upsert on BelongsTo INPUT_FIELD_DEFINITION - -**Goal:** Prove that `@upsert` on a BelongsTo field creates the related model and sets the FK before the parent saves. - -**Files:** -- Modify: `tests/Integration/Schema/Directives/CreateDirectiveTest.php` - -**Acceptance Criteria:** -- [ ] Test creates a Task with `@upsert` on a BelongsTo `user` field -- [ ] FK is set before parent save (no integrity violation) -- [ ] Related model is created and associated correctly -- [ ] Test for precedence over implicit relation detection (argument named same as relation) - -**Verify:** `docker compose run --rm php vendor/bin/phpunit --filter=testUpsertBelongsToBeforeSave` → PASS - -**Steps:** - -- [ ] **Step 1: Write integration test** - -Add to `tests/Integration/Schema/Directives/CreateDirectiveTest.php`: - -```php - public function testUpsertBelongsToBeforeSave(): void - { - $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' - type Task { - id: ID! - name: String! - user: User @belongsTo - } - - type User { - id: ID! - name: String! - } - - type Mutation { - createTask(input: CreateTaskInput! @spread): Task @create - } - - input CreateTaskInput { - name: String! - user: CreateUserInput @upsert - } - - input CreateUserInput { - id: ID - name: String! - } - GRAPHQL; - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - createTask(input: { - name: "My task" - user: { - name: "New User" - } - }) { - id - name - user { - id - name - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'createTask' => [ - 'name' => 'My task', - 'user' => [ - 'id' => '1', - 'name' => 'New User', - ], - ], - ], - ]); - } - - public function testUpsertBelongsToTakesPrecedenceOverImplicitRelation(): void - { - $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' - type Task { - id: ID! - name: String! - user: User @belongsTo - } - - type User { - id: ID! - name: String! - } - - type Mutation { - createTask(input: CreateTaskInput! @spread): Task @create - } - - input CreateTaskInput { - name: String! - user: CreateUserInput @upsert - } - - input CreateUserInput { - id: ID - name: String! - } - GRAPHQL; - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - createTask(input: { - name: "My task" - user: { - name: "Created via directive" - } - }) { - id - name - user { - name - } - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'createTask' => [ - 'name' => 'My task', - 'user' => [ - 'name' => 'Created via directive', - ], - ], - ], - ]); - } -``` - -- [ ] **Step 2: Run tests** - -Run: `docker compose run --rm php vendor/bin/phpunit --filter=testUpsertBelongsTo` -Expected: PASS (both tests) - -- [ ] **Step 3: Commit** - -```bash -git -C /home/bfranke/projects/lighthouse add tests/Integration/Schema/Directives/CreateDirectiveTest.php && git -C /home/bfranke/projects/lighthouse commit -m "Test @upsert on BelongsTo INPUT_FIELD_DEFINITION" -``` - ---- - -### Task 6: Test @geocode custom directive (non-relation pre-save) - -**Goal:** Prove that a custom `SaveAwareArgResolver` that always returns `true` can set model attributes before save without being relation-bound. - -**Files:** -- Create: `tests/Utils/Directives/GeocodeDirective.php` -- Modify: `tests/Integration/Schema/Directives/CreateDirectiveTest.php` - -**Acceptance Criteria:** -- [ ] `@geocode` directive implements `SaveAwareArgResolver` with `runBeforeSave() => true` -- [ ] Takes an `AddressInput` argument, sets `latitude` and `longitude` on the model -- [ ] Integration test proves attributes are persisted in a single save - -**Verify:** `docker compose run --rm php vendor/bin/phpunit --filter=testGeocodePreSaveArgResolver` → PASS - -**Steps:** - -- [ ] **Step 1: Create GeocodeDirective test fixture** - -Create `tests/Utils/Directives/GeocodeDirective.php`: - -```php -toArray(); - $model->setAttribute('latitude', $address['lat'] ?? 0.0); - $model->setAttribute('longitude', $address['lng'] ?? 0.0); - } -} -``` - -- [ ] **Step 2: Add migration for lat/lng columns on users table** - -Check if there's an existing way to add columns in tests. The test models use the existing migrations in `tests/database/migrations/`. We need a model with lat/lng columns. The `User` model's migration is at `tests/database/migrations/2018_02_28_000000_create_testbench_users_table.php`. Rather than modifying existing migrations, use a model that can have arbitrary attributes (Users table supports `$guarded = []`). - -Actually, Lighthouse tests use `$model->fill()` / `$model->forceFill()` depending on config. The test should use a table that has these columns. Let's check what columns the users table has: - -Look at the users migration — if it doesn't have lat/lng we need a different approach. Since we can't modify existing migrations without risk, let's use a simpler approach: create an inline migration in the test setUp, or use a model with JSON/text columns we can repurpose. - -Alternatively — the `@geocode` directive sets attributes via `setAttribute`. If the model uses `$guarded = []` and the table has the columns, it works. Since we can't guarantee lat/lng columns, let's set existing string columns on the User model instead (e.g. repurpose existing nullable columns). - -Better approach: use `Task` model which has `name` and `guard_test` columns, and have geocode set a known column. But that's contrived. - -Simplest: add a test migration. Lighthouse tests already have a pattern for this — add a new migration file: - -Create `tests/database/migrations/2026_06_30_000000_add_geocode_columns_to_users_table.php`: - -```php -float('latitude')->nullable(); - $table->float('longitude')->nullable(); - }); - } - - public function down(): void - { - Schema::table('users', function (Blueprint $table): void { - $table->dropColumn('latitude', 'longitude'); - }); - } -}; -``` - -- [ ] **Step 3: Write integration test** - -Add to `tests/Integration/Schema/Directives/CreateDirectiveTest.php`: - -```php - public function testGeocodePreSaveArgResolver(): void - { - $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' - type User { - id: ID! - name: String! - latitude: Float - longitude: Float - } - - type Mutation { - createUser(input: CreateUserInput! @spread): User @create - } - - input CreateUserInput { - name: String! - location: LocationInput @geocode - } - - input LocationInput { - lat: Float! - lng: Float! - } - GRAPHQL; - - $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' - mutation { - createUser(input: { - name: "Geo User" - location: { - lat: 48.1351 - lng: 11.5820 - } - }) { - id - name - latitude - longitude - } - } - GRAPHQL)->assertJson([ - 'data' => [ - 'createUser' => [ - 'name' => 'Geo User', - 'latitude' => 48.1351, - 'longitude' => 11.582, - ], - ], - ]); - } -``` - -- [ ] **Step 4: Run test** - -Run: `docker compose run --rm php vendor/bin/phpunit --filter=testGeocodePreSaveArgResolver` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git -C /home/bfranke/projects/lighthouse add tests/Utils/Directives/GeocodeDirective.php tests/database/migrations/2026_06_30_000000_add_geocode_columns_to_users_table.php tests/Integration/Schema/Directives/CreateDirectiveTest.php && git -C /home/bfranke/projects/lighthouse commit -m "Test @geocode non-relation SaveAwareArgResolver" -``` - ---- - -### Task 7: Test SaveAwareArgResolver inside @nest (non-Model root) - -**Goal:** Prove that a `SaveAwareArgResolver` inside `@nest` still runs correctly when the root is not yet a Model (falls back to post-save position). - -**Files:** -- Modify: `tests/Unit/Execution/Arguments/ArgPartitionerTest.php` - -**Acceptance Criteria:** -- [ ] Unit test creates a `SaveAwareArgResolver` fixture -- [ ] Passes non-Model root to `nestedArgResolvers` -- [ ] The resolver ends up in the "nested" (post-save) partition, not excluded -- [ ] No crash or error - -**Verify:** `docker compose run --rm php vendor/bin/phpunit --filter=testSaveAwareArgResolverWithNonModelRoot` → PASS - -**Steps:** - -- [ ] **Step 1: Create test fixture** - -Create `tests/Unit/Execution/Arguments/Fixtures/SaveAwareNested.php`: - -```php -arguments['regular'] = $regular; - - $saveAware = new Argument(); - $saveAware->directives->push(new SaveAwareNested()); - $argumentSet->arguments['saveAware'] = $saveAware; - - [$nestedArgs, $regularArgs] = ArgPartitioner::nestedArgResolvers($argumentSet, null); - - $this->assertSame( - ['regular' => $regular], - $regularArgs->arguments, - ); - - $this->assertSame( - ['saveAware' => $saveAware], - $nestedArgs->arguments, - 'SaveAwareArgResolver should be in nested (post-save) set when root is not a Model', - ); - } - - public function testSaveAwareArgResolverWithModelRoot(): void - { - $argumentSet = new ArgumentSet(); - - $regular = new Argument(); - $argumentSet->arguments['regular'] = $regular; - - $saveAware = new Argument(); - $saveAware->directives->push(new SaveAwareNested()); - $argumentSet->arguments['saveAware'] = $saveAware; - - [$nestedArgs, $regularArgs] = ArgPartitioner::nestedArgResolvers($argumentSet, new User()); - - $this->assertSame( - ['regular' => $regular, 'saveAware' => $saveAware], - $regularArgs->arguments, - 'SaveAwareArgResolver with runBeforeSave=true should be excluded from nested set when root is Model', - ); - - $this->assertSame( - [], - $nestedArgs->arguments, - ); - } -``` - -- [ ] **Step 3: Run tests** - -Run: `docker compose run --rm php vendor/bin/phpunit tests/Unit/Execution/Arguments/ArgPartitionerTest.php` -Expected: PASS (all tests including existing ones) - -- [ ] **Step 4: Commit** - -```bash -git -C /home/bfranke/projects/lighthouse add tests/Unit/Execution/Arguments/Fixtures/SaveAwareNested.php tests/Unit/Execution/Arguments/ArgPartitionerTest.php && git -C /home/bfranke/projects/lighthouse commit -m "Test SaveAwareArgResolver partitioning with Model and non-Model roots" -``` - ---- - -### Task 8: Update CHANGELOG and run full test suite - -**Goal:** Document the change and confirm nothing is broken. - -**Files:** -- Modify: `CHANGELOG.md` - -**Acceptance Criteria:** -- [ ] CHANGELOG has entry under `## Unreleased` → `Added` -- [ ] Full test suite passes -- [ ] PHPStan passes - -**Verify:** `docker compose run --rm php sh -c 'vendor/bin/phpstan && vendor/bin/phpunit'` → OK + all tests pass - -**Steps:** - -- [ ] **Step 1: Update CHANGELOG** - -Add under `## Unreleased` in `CHANGELOG.md`: - -```markdown -### Added - -- `SaveAwareArgResolver` interface for directives that need control over pre/post-save timing in mutations https://github.com/nuwave/lighthouse/pull/2777 -``` - -- [ ] **Step 2: Run full checks** - -Run: `docker compose run --rm php sh -c 'vendor/bin/phpstan && vendor/bin/phpunit'` -Expected: No errors, all tests pass - -- [ ] **Step 3: Run code fixer** - -Run: `docker compose run --rm php vendor/bin/php-cs-fixer fix` -Expected: No changes (or only formatting fixes) - -- [ ] **Step 4: Commit** - -```bash -git -C /home/bfranke/projects/lighthouse add CHANGELOG.md && git -C /home/bfranke/projects/lighthouse commit -m "Add CHANGELOG entry for SaveAwareArgResolver" -``` - -If php-cs-fixer made changes: - -```bash -git -C /home/bfranke/projects/lighthouse add -u && git -C /home/bfranke/projects/lighthouse commit -m "Apply php-cs-fixer changes" -``` diff --git a/docs/superpowers/plans/2026-06-30-save-aware-arg-resolver.md.tasks.json b/docs/superpowers/plans/2026-06-30-save-aware-arg-resolver.md.tasks.json deleted file mode 100644 index 1ee7367801..0000000000 --- a/docs/superpowers/plans/2026-06-30-save-aware-arg-resolver.md.tasks.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "planPath": "docs/superpowers/plans/2026-06-30-save-aware-arg-resolver.md", - "tasks": [ - { - "id": 5, - "subject": "Task 1: Reset branch to master baseline for affected files", - "status": "pending", - "description": "**Goal:** Remove all branch-only `PreSaveArgResolver` code so we start clean from master for the new design.\n\n**Files:**\n- Delete: `src/Support/Contracts/PreSaveArgResolver.php`\n- Delete: `tests/Utils/Directives/ConnectRelatedDirective.php`\n- Delete: `tests/Unit/Execution/Arguments/Fixtures/PreNested.php`\n- Revert: `src/Execution/Arguments/ArgPartitioner.php` (to master)\n- Revert: `src/Execution/Arguments/ResolveNested.php` (to master)\n- Revert: `src/Execution/Arguments/SaveModel.php` (to master)\n- Revert: `src/Support/Contracts/ArgResolver.php` (to master)\n- Revert: `tests/Unit/Execution/Arguments/ArgPartitionerTest.php` (to master)\n- Revert: `tests/Integration/Schema/Directives/CreateDirectiveTest.php` (to master)\n\n**Acceptance Criteria:**\n- [ ] `PreSaveArgResolver.php` deleted\n- [ ] `ConnectRelatedDirective.php` deleted\n- [ ] `PreNested.php` deleted\n- [ ] Core src files match master\n- [ ] Tests pass\n\n**Verify:** `docker compose run --rm php vendor/bin/phpunit tests/Unit/Execution/Arguments/ArgPartitionerTest.php`\n\n```json:metadata\n{\"files\": [\"src/Support/Contracts/PreSaveArgResolver.php\", \"tests/Utils/Directives/ConnectRelatedDirective.php\", \"tests/Unit/Execution/Arguments/Fixtures/PreNested.php\", \"src/Execution/Arguments/ArgPartitioner.php\", \"src/Execution/Arguments/ResolveNested.php\", \"src/Execution/Arguments/SaveModel.php\"], \"verifyCommand\": \"docker compose run --rm php vendor/bin/phpunit tests/Unit/Execution/Arguments/ArgPartitionerTest.php\", \"acceptanceCriteria\": [\"PreSaveArgResolver.php deleted\", \"ConnectRelatedDirective.php deleted\", \"PreNested.php deleted\", \"Core src files match master\", \"Tests pass\"], \"modelTier\": \"mechanical\"}\n```" - }, - { - "id": 6, - "subject": "Task 2: Create SaveAwareArgResolver interface", - "status": "pending", - "blockedBy": [5], - "description": "**Goal:** Define the new interface with `@api` stability guarantee.\n\n**Files:**\n- Create: `src/Support/Contracts/SaveAwareArgResolver.php`\n- Modify: `src/Support/Contracts/ArgResolver.php` (add `@api`)\n\n**Acceptance Criteria:**\n- [ ] Interface extends `ArgResolver`\n- [ ] Has `runBeforeSave(Model $model): bool` method\n- [ ] Both have `@api` annotation\n- [ ] PHPStan passes\n\n**Verify:** `docker compose run --rm php vendor/bin/phpstan analyse src/Support/Contracts/SaveAwareArgResolver.php src/Support/Contracts/ArgResolver.php`\n\n```json:metadata\n{\"files\": [\"src/Support/Contracts/SaveAwareArgResolver.php\", \"src/Support/Contracts/ArgResolver.php\"], \"verifyCommand\": \"docker compose run --rm php vendor/bin/phpstan analyse src/Support/Contracts/SaveAwareArgResolver.php src/Support/Contracts/ArgResolver.php\", \"acceptanceCriteria\": [\"Interface extends ArgResolver\", \"Has runBeforeSave(Model) method\", \"Both have @api annotation\", \"PHPStan passes\"], \"modelTier\": \"mechanical\"}\n```" - }, - { - "id": 7, - "subject": "Task 3: Implement SaveAwareArgResolver in ModelMutationDirective", - "status": "pending", - "blockedBy": [6], - "description": "**Goal:** Make `@upsert`, `@create`, and `@update` dynamically decide pre/post-save timing based on relation type.\n\n**Files:**\n- Modify: `src/Schema/Directives/ModelMutationDirective.php`\n\n**Acceptance Criteria:**\n- [ ] `ModelMutationDirective` implements `SaveAwareArgResolver`\n- [ ] New `relationName(): string` method extracted from `__invoke()`\n- [ ] `runBeforeSave()` returns true for BelongsTo relations (which includes MorphTo)\n- [ ] `__invoke()` uses `$this->relationName()`\n- [ ] PHPStan passes\n\n**Verify:** `docker compose run --rm php vendor/bin/phpstan analyse src/Schema/Directives/ModelMutationDirective.php`\n\n```json:metadata\n{\"files\": [\"src/Schema/Directives/ModelMutationDirective.php\"], \"verifyCommand\": \"docker compose run --rm php vendor/bin/phpstan analyse src/Schema/Directives/ModelMutationDirective.php\", \"acceptanceCriteria\": [\"Implements SaveAwareArgResolver\", \"relationName() method extracted\", \"runBeforeSave returns true for BelongsTo\", \"__invoke uses relationName()\", \"PHPStan passes\"], \"modelTier\": \"standard\"}\n```" - }, - { - "id": 8, - "subject": "Task 4: Update ArgPartitioner and SaveModel orchestration", - "status": "pending", - "blockedBy": [6, 7], - "description": "**Goal:** Make `nestedArgResolvers` exclude pre-save resolvers (passing them to SaveModel), and make SaveModel extract and invoke them before save.\n\n**Files:**\n- Modify: `src/Execution/Arguments/ArgPartitioner.php`\n- Modify: `src/Execution/Arguments/SaveModel.php`\n\n**Acceptance Criteria:**\n- [ ] `nestedArgResolvers` excludes `SaveAwareArgResolver` resolvers where `runBeforeSave($root)` is true (only when root is Model)\n- [ ] New `preSaveNestedArgResolvers(ArgumentSet, Model): array` method on ArgPartitioner\n- [ ] `SaveModel` extracts pre-save resolvers before implicit BelongsTo detection\n- [ ] `SaveModel` invokes pre-save resolvers before `$model->save()`\n- [ ] PHPStan passes\n\n**Verify:** `docker compose run --rm php vendor/bin/phpstan analyse src/Execution/Arguments/ArgPartitioner.php src/Execution/Arguments/SaveModel.php`\n\n```json:metadata\n{\"files\": [\"src/Execution/Arguments/ArgPartitioner.php\", \"src/Execution/Arguments/SaveModel.php\"], \"verifyCommand\": \"docker compose run --rm php vendor/bin/phpstan analyse src/Execution/Arguments/ArgPartitioner.php src/Execution/Arguments/SaveModel.php\", \"acceptanceCriteria\": [\"nestedArgResolvers excludes pre-save resolvers when root is Model\", \"preSaveNestedArgResolvers method added\", \"SaveModel extracts pre-save before BelongsTo detection\", \"SaveModel invokes pre-save resolvers before save\", \"PHPStan passes\"], \"modelTier\": \"standard\"}\n```" - }, - { - "id": 9, - "subject": "Task 5: Test @upsert on BelongsTo INPUT_FIELD_DEFINITION", - "status": "pending", - "blockedBy": [8], - "description": "**Goal:** Prove that `@upsert` on a BelongsTo field creates the related model and sets the FK before the parent saves.\n\n**Files:**\n- Modify: `tests/Integration/Schema/Directives/CreateDirectiveTest.php`\n\n**Acceptance Criteria:**\n- [ ] Test creates a Task with `@upsert` on a BelongsTo `user` field\n- [ ] FK is set before parent save (no integrity violation)\n- [ ] Related model is created and associated correctly\n- [ ] Test for precedence over implicit relation detection\n\n**Verify:** `docker compose run --rm php vendor/bin/phpunit --filter=testUpsertBelongsTo`\n\n```json:metadata\n{\"files\": [\"tests/Integration/Schema/Directives/CreateDirectiveTest.php\"], \"verifyCommand\": \"docker compose run --rm php vendor/bin/phpunit --filter=testUpsertBelongsTo\", \"acceptanceCriteria\": [\"Test creates Task with @upsert on BelongsTo\", \"FK set before parent save\", \"Related model created and associated\", \"Precedence over implicit relation detection\"], \"modelTier\": \"standard\"}\n```" - }, - { - "id": 10, - "subject": "Task 6: Test @geocode custom directive (non-relation pre-save)", - "status": "pending", - "blockedBy": [8], - "description": "**Goal:** Prove that a custom `SaveAwareArgResolver` that always returns `true` can set model attributes before save without being relation-bound.\n\n**Files:**\n- Create: `tests/Utils/Directives/GeocodeDirective.php`\n- Create: `tests/database/migrations/2026_06_30_000000_add_geocode_columns_to_users_table.php`\n- Modify: `tests/Integration/Schema/Directives/CreateDirectiveTest.php`\n\n**Acceptance Criteria:**\n- [ ] `@geocode` directive implements `SaveAwareArgResolver` with `runBeforeSave() => true`\n- [ ] Takes an `AddressInput` argument, sets `latitude` and `longitude` on the model\n- [ ] Integration test proves attributes are persisted in a single save\n\n**Verify:** `docker compose run --rm php vendor/bin/phpunit --filter=testGeocodePreSaveArgResolver`\n\n```json:metadata\n{\"files\": [\"tests/Utils/Directives/GeocodeDirective.php\", \"tests/database/migrations/2026_06_30_000000_add_geocode_columns_to_users_table.php\", \"tests/Integration/Schema/Directives/CreateDirectiveTest.php\"], \"verifyCommand\": \"docker compose run --rm php vendor/bin/phpunit --filter=testGeocodePreSaveArgResolver\", \"acceptanceCriteria\": [\"@geocode implements SaveAwareArgResolver\", \"Takes AddressInput, sets lat/lng on model\", \"Integration test proves single-save persistence\"], \"modelTier\": \"standard\"}\n```" - }, - { - "id": 11, - "subject": "Task 7: Test SaveAwareArgResolver inside @nest (non-Model root)", - "status": "pending", - "blockedBy": [8], - "description": "**Goal:** Prove that a `SaveAwareArgResolver` inside a non-Model context still runs correctly (falls back to post-save position).\n\n**Files:**\n- Create: `tests/Unit/Execution/Arguments/Fixtures/SaveAwareNested.php`\n- Modify: `tests/Unit/Execution/Arguments/ArgPartitionerTest.php`\n\n**Acceptance Criteria:**\n- [ ] SaveAwareNested fixture created\n- [ ] Non-Model root: resolver in nested (post-save) set\n- [ ] Model root: resolver excluded from nested set\n- [ ] No crash\n\n**Verify:** `docker compose run --rm php vendor/bin/phpunit tests/Unit/Execution/Arguments/ArgPartitionerTest.php`\n\n```json:metadata\n{\"files\": [\"tests/Unit/Execution/Arguments/Fixtures/SaveAwareNested.php\", \"tests/Unit/Execution/Arguments/ArgPartitionerTest.php\"], \"verifyCommand\": \"docker compose run --rm php vendor/bin/phpunit tests/Unit/Execution/Arguments/ArgPartitionerTest.php\", \"acceptanceCriteria\": [\"SaveAwareNested fixture created\", \"Non-Model root: resolver in nested set\", \"Model root: resolver excluded from nested set\", \"No crash\"], \"modelTier\": \"mechanical\"}\n```" - }, - { - "id": 12, - "subject": "Task 8: Update CHANGELOG and run full test suite", - "status": "pending", - "blockedBy": [9, 10, 11], - "description": "**Goal:** Document the change and confirm nothing is broken.\n\n**Files:**\n- Modify: `CHANGELOG.md`\n\n**Acceptance Criteria:**\n- [ ] CHANGELOG entry under `## Unreleased` → `Added`\n- [ ] Full test suite passes\n- [ ] PHPStan passes\n- [ ] php-cs-fixer clean\n\n**Verify:** `docker compose run --rm php sh -c 'vendor/bin/phpstan && vendor/bin/phpunit'`\n\n```json:metadata\n{\"files\": [\"CHANGELOG.md\"], \"verifyCommand\": \"docker compose run --rm php sh -c 'vendor/bin/phpstan && vendor/bin/phpunit'\", \"acceptanceCriteria\": [\"CHANGELOG entry added\", \"Full test suite passes\", \"PHPStan passes\", \"php-cs-fixer clean\"], \"modelTier\": \"mechanical\"}\n```" - } - ], - "lastUpdated": "2026-06-30T12:00:00Z" -} diff --git a/docs/superpowers/specs/2026-06-30-save-aware-arg-resolver-design.md b/docs/superpowers/specs/2026-06-30-save-aware-arg-resolver-design.md deleted file mode 100644 index f440848df7..0000000000 --- a/docs/superpowers/specs/2026-06-30-save-aware-arg-resolver-design.md +++ /dev/null @@ -1,172 +0,0 @@ -# SaveAwareArgResolver Design - -## Problem - -`@upsert`, `@create` and `@update` on `INPUT_FIELD_DEFINITION` always run post-save. -When the field targets a BelongsTo relation, they need to run before the parent saves so the FK is available. -Custom directives that set model attributes from complex input (e.g. geocoding) also need pre-save timing without being relation-bound. - -## Constraints - -- `ArgResolver::__invoke(mixed $root, mixed $value)` -- root is `mixed`, not always a Model. -- Pre/post-save is only meaningful inside `SaveModel`'s orchestration. -- Post-save must stay default for backwards compatibility. -- A static marker interface doesn't work for `@upsert` because the same directive handles both BelongsTo (pre) and HasMany (post). -- limes-api extends `ArgPartitioner` with a custom partitioner -- the system is used beyond `SaveModel`. - -## Interface - -```php -namespace Nuwave\Lighthouse\Support\Contracts; - -use Illuminate\Database\Eloquent\Model; - -/** - * PHPDoc TBD describes when this interface may be needed - * @api - */ -interface SaveAwareArgResolver extends ArgResolver -{ - /** PHPDoc TBD — describes when the orchestrator calls this and what true/false means. */ - public function runBeforeSave(Model $model): bool; -} -``` - -- Extends `ArgResolver` -- a specialization, not a replacement. -- `@api` -- stability guarantee, consumers can implement this. `ArgResolver` also gets `@api` -- Receives the Model so the decision can be contextual (relation type introspection). -- Replaces `PreSaveArgResolver` (branch-only, never shipped). - -## Implementors - -### ModelMutationDirective - -Extracts a `relationName()` method (reused by `__invoke()` and `runBeforeSave()`): - -```php -protected function relationName(): string -{ - return $this->directiveArgValue('relation', $this->nodeName()); -} - -public function runBeforeSave(Model $model): bool -{ - return ArgPartitioner::methodReturnsRelation( - new \ReflectionClass($model), - $this->relationName(), - BelongsTo::class, - ); -} -``` - -BelongsTo (MorphTo extends from it) returns true (pre-save), everything else returns false (post-save). - -### Custom directives (e.g. @geocode test fixture) - -```php -public function runBeforeSave(Model $model): bool -{ - return true; -} -``` - -Always pre-save -- sets attributes on the model from complex input. - -## Orchestration - -### nestedArgResolvers (name kept) - -Extended to exclude `SaveAwareArgResolver` resolvers where `runBeforeSave()` returns true. -Only checks when root is a Model -- non-Model contexts skip the check entirely: - -```php -/** PHPDoc TBD clarify the implicit post-save default (explicit for SaveAware) */ -public static function nestedArgResolvers(ArgumentSet $argumentSet, mixed $root): array -{ - // ... attach resolvers (unchanged) ... - - return static::partition( - $argumentSet, - static function (string $name, Argument $argument) use ($root): bool { - $resolver = $argument->resolver; - if ($resolver === null) { - return false; - } - - if ($resolver instanceof SaveAwareArgResolver - && $root instanceof Model - && $resolver->runBeforeSave($root) - ) { - return false; - } - - return true; - }, - ); -} -``` - -### SaveModel - -Extracts pre-save resolvers and runs them before `$model->save()`: - -```php -[$preSave, $remaining] = ArgPartitioner::preSaveNestedArgResolvers($remaining, $model); - -// ... fill model, resolve implicit BelongsTo/MorphTo ... - -foreach ($preSave->arguments as $nested) { - $resolver = $nested->resolver; - assert($resolver instanceof SaveAwareArgResolver); - $resolver($model, $nested->value); -} - -// ... save ... -``` - -Pre-save extraction happens before implicit `relationMethods(BelongsTo)` so directive-annotated fields aren't captured by name-based relation detection. - -### Non-Model contexts (@nest) - -When root is not a Model, `SaveAwareArgResolver` resolvers are treated as regular post-save. -The interface is inert outside a Model/save context. - -## Test Coverage - -1. **BelongsTo via @upsert on INPUT_FIELD_DEFINITION** -- creates the related model and associates FK before parent saves. -2. **@geocode custom directive** -- non-relation `SaveAwareArgResolver` that always returns true. Takes complex input, sets lat/lng on the model. Verifies attributes are present in a single save. -3. **SaveAwareArgResolver inside @nest** -- root is not a Model. Verifies the resolver still runs (post-save path), doesn't crash. -4. **Existing @upsert on HasMany** -- continues to pass (same directive, post-save timing). - -## Migration & BC - -From master: - -- **nestedArgResolvers** -- name kept, logic extended with `SaveAwareArgResolver` check. -- **SaveModel** -- gains pre-save extraction and execution loop. -- **ModelMutationDirective** -- implements `SaveAwareArgResolver`, adds `relationName()`. - -Added: - -- `SaveAwareArgResolver` interface (new, `@api`). -- `@geocode` test fixture directive. -- Tests for all three scenarios above. - -Removed (branch-only, never shipped): - -- `PreSaveArgResolver` interface. -- `@connectRelated` test fixture directive. -- `preSaveArgResolvers()` static method (folded into `nestedArgResolvers`). -- The rename to `postSaveArgResolvers` in `ResolveNested`. - -Unchanged: - -- `nestedArgResolvers` name and signature. -- `ResolveNested` default partitioner reference. -- `ArgResolver` interface. -- Implicit relation detection in `attachNestedArgResolver`. -- limes-api's extended `ArgPartitioner` -- unaffected. - -## Changelog - -`Added` -- `SaveAwareArgResolver` interface for directives that need control over pre/post-save timing in mutations. diff --git a/src/Execution/Arguments/ArgPartitioner.php b/src/Execution/Arguments/ArgPartitioner.php index 325295dd82..12000f65dc 100644 --- a/src/Execution/Arguments/ArgPartitioner.php +++ b/src/Execution/Arguments/ArgPartitioner.php @@ -19,9 +19,7 @@ class ArgPartitioner /** * Partition the arguments into nested (post-save) and regular. * - * Resolvers implementing SaveAwareArgResolver that return true from - * runBeforeSave() are excluded from the nested set when the root is a Model, - * allowing SaveModel to handle them before persisting. + * Resolvers implementing SaveAwareArgResolver that return true from runBeforeSave() are excluded from the nested set when the root is a Model, allowing SaveModel to handle them before persisting. * * @return array{ * 0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, @@ -46,20 +44,13 @@ static function (string $name, Argument $argument) use ($root): bool { return false; } - if ($resolver instanceof SaveAwareArgResolver - && $root instanceof Model - && $resolver->runBeforeSave($root) - ) { - return false; - } - - return true; + return ! ($resolver instanceof SaveAwareArgResolver && $root instanceof Model && $resolver->runBeforeSave($root)); }, ); } /** - * Partition arguments into those with a pre-save resolver and the rest. + * Requires that resolvers have been attached via nestedArgResolvers() first. * * @return array{ * 0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, diff --git a/src/Schema/Directives/ModelMutationDirective.php b/src/Schema/Directives/ModelMutationDirective.php index 75a7cd9165..d61b1999a9 100644 --- a/src/Schema/Directives/ModelMutationDirective.php +++ b/src/Schema/Directives/ModelMutationDirective.php @@ -38,12 +38,16 @@ public function runBeforeSave(Model $model): bool /** * @param Model $model - * @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet|array<\Nuwave\Lighthouse\Execution\Arguments\ArgumentSet> $args + * @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet|array<\Nuwave\Lighthouse\Execution\Arguments\ArgumentSet>|null $args * - * @return \Illuminate\Database\Eloquent\Model|array<\Illuminate\Database\Eloquent\Model> + * @return \Illuminate\Database\Eloquent\Model|array<\Illuminate\Database\Eloquent\Model>|null */ public function __invoke($model, $args): mixed { + if ($args === null) { + return null; + } + $relation = $model->{$this->relationName()}(); assert($relation instanceof Relation); diff --git a/src/Support/Contracts/SaveAwareArgResolver.php b/src/Support/Contracts/SaveAwareArgResolver.php index 76b04ffe92..0d266a1f40 100644 --- a/src/Support/Contracts/SaveAwareArgResolver.php +++ b/src/Support/Contracts/SaveAwareArgResolver.php @@ -21,8 +21,7 @@ interface SaveAwareArgResolver extends ArgResolver * for any ArgResolver that does not implement this interface). * * Only consulted when the root is a Model inside SaveModel's orchestration. - * In non-Model contexts (e.g. @nest), this method is not called and the - * resolver runs in the default post-save position. + * In non-Model contexts (e.g. @nest), this method is not called and the resolver runs in the default post-save position. */ public function runBeforeSave(Model $model): bool; } diff --git a/tests/Integration/Schema/Directives/CreateDirectiveTest.php b/tests/Integration/Schema/Directives/CreateDirectiveTest.php index 70d6679103..685e662a66 100644 --- a/tests/Integration/Schema/Directives/CreateDirectiveTest.php +++ b/tests/Integration/Schema/Directives/CreateDirectiveTest.php @@ -588,27 +588,27 @@ public function testTurnOnMassAssignment(): void public function testUpsertBelongsToBeforeSave(): void { $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' - type Task { + type Post { id: ID! - name: String! - user: User @belongsTo + title: String! + task: Task @belongsTo } - type User { + type Task { id: ID! name: String! } type Mutation { - createTask(input: CreateTaskInput! @spread): Task @create + createPost(input: CreatePostInput! @spread): Post @create } - input CreateTaskInput { - name: String! - user: CreateUserInput @upsert + input CreatePostInput { + title: String! + task: UpsertTaskInput @upsert } - input CreateUserInput { + input UpsertTaskInput { id: ID name: String! } @@ -616,15 +616,15 @@ public function testUpsertBelongsToBeforeSave(): void $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' mutation { - createTask(input: { - name: "My task" - user: { - name: "New User" + createPost(input: { + title: "My post" + task: { + name: "New Task" } }) { id - name - user { + title + task { id name } @@ -632,11 +632,11 @@ public function testUpsertBelongsToBeforeSave(): void } GRAPHQL)->assertJson([ 'data' => [ - 'createTask' => [ - 'name' => 'My task', - 'user' => [ + 'createPost' => [ + 'title' => 'My post', + 'task' => [ 'id' => '1', - 'name' => 'New User', + 'name' => 'New Task', ], ], ], @@ -644,6 +644,72 @@ public function testUpsertBelongsToBeforeSave(): void } public function testUpsertBelongsToTakesPrecedenceOverImplicitRelation(): void + { + $task = new Task(); + $task->name = 'Original name'; + $task->save(); + + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Post { + id: ID! + title: String! + task: Task @belongsTo + } + + type Task { + id: ID! + name: String! + } + + type Mutation { + createPost(input: CreatePostInput! @spread): Post @create + } + + input CreatePostInput { + title: String! + task: UpsertTaskInput @upsert + } + + input UpsertTaskInput { + id: ID + name: String! + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<id}" + name: "Updated via directive" + } + }) { + id + title + task { + id + name + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createPost' => [ + 'title' => 'My post', + 'task' => [ + 'id' => (string) $task->id, + 'name' => 'Updated via directive', + ], + ], + ], + ]); + + $task->refresh(); + $this->assertSame('Updated via directive', $task->name); + } + + public function testUpsertBelongsToWithNullValue(): void { $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' type Task { @@ -676,14 +742,12 @@ public function testUpsertBelongsToTakesPrecedenceOverImplicitRelation(): void mutation { createTask(input: { name: "My task" - user: { - name: "Created via directive" - } + user: null }) { id name user { - name + id } } } @@ -691,9 +755,7 @@ public function testUpsertBelongsToTakesPrecedenceOverImplicitRelation(): void 'data' => [ 'createTask' => [ 'name' => 'My task', - 'user' => [ - 'name' => 'Created via directive', - ], + 'user' => null, ], ], ]); diff --git a/tests/Unit/Execution/Arguments/ArgPartitionerTest.php b/tests/Unit/Execution/Arguments/ArgPartitionerTest.php index 0854df2ce6..62aa900d6c 100644 --- a/tests/Unit/Execution/Arguments/ArgPartitionerTest.php +++ b/tests/Unit/Execution/Arguments/ArgPartitionerTest.php @@ -7,6 +7,7 @@ use Nuwave\Lighthouse\Execution\Arguments\ArgPartitioner; use Nuwave\Lighthouse\Execution\Arguments\Argument; use Nuwave\Lighthouse\Execution\Arguments\ArgumentSet; +use Nuwave\Lighthouse\Execution\Arguments\ResolveNested; use Tests\TestCase; use Tests\Unit\Execution\Arguments\Fixtures\Nested; use Tests\Unit\Execution\Arguments\Fixtures\SaveAwareNested; @@ -161,4 +162,46 @@ public function testSaveAwareArgResolverWithModelRoot(): void $nestedArgs->arguments, ); } + + public function testSaveAwareArgResolverExecutesWithNonModelRoot(): void + { + $argumentSet = new ArgumentSet(); + + $saveAwareResolver = new SaveAwareNested(); + + $saveAware = new Argument(); + $saveAware->value = new ArgumentSet(); + $saveAware->directives->push($saveAwareResolver); + $argumentSet->arguments['saveAware'] = $saveAware; + + $nonModelRoot = new \stdClass(); + $resolveNested = new ResolveNested(); + $resolveNested($nonModelRoot, $argumentSet); + + $this->assertTrue($saveAwareResolver->wasCalled, 'SaveAwareArgResolver should execute when root is not a Model'); + $this->assertSame($nonModelRoot, $saveAwareResolver->receivedRoot); + } + + public function testPreSaveNestedArgResolversIncludesNullValues(): void + { + $argumentSet = new ArgumentSet(); + + $saveAwareResolver = new SaveAwareNested(); + + $nullArg = new Argument(); + $nullArg->value = null; + $nullArg->resolver = $saveAwareResolver; + $argumentSet->arguments['nullField'] = $nullArg; + + $nonNullArg = new Argument(); + $nonNullArg->value = new ArgumentSet(); + $nonNullArg->resolver = $saveAwareResolver; + $argumentSet->arguments['nonNullField'] = $nonNullArg; + + [$preSave, $remaining] = ArgPartitioner::preSaveNestedArgResolvers($argumentSet, new User()); + + $this->assertArrayHasKey('nullField', $preSave->arguments, 'Null-valued args should not be filtered from pre-save set'); + $this->assertArrayHasKey('nonNullField', $preSave->arguments); + $this->assertSame([], $remaining->arguments); + } } diff --git a/tests/Unit/Execution/Arguments/Fixtures/SaveAwareNested.php b/tests/Unit/Execution/Arguments/Fixtures/SaveAwareNested.php index ab3475c2c3..471a14788a 100644 --- a/tests/Unit/Execution/Arguments/Fixtures/SaveAwareNested.php +++ b/tests/Unit/Execution/Arguments/Fixtures/SaveAwareNested.php @@ -8,7 +8,15 @@ final class SaveAwareNested extends BaseDirective implements SaveAwareArgResolver { - public function __invoke(mixed $root, $args): void {} + public bool $wasCalled = false; + + public mixed $receivedRoot = null; + + public function __invoke(mixed $root, $args): void + { + $this->wasCalled = true; + $this->receivedRoot = $root; + } public function runBeforeSave(Model $model): bool { diff --git a/tests/Utils/Directives/GeocodeDirective.php b/tests/Utils/Directives/GeocodeDirective.php index 811886a85e..440f739ea0 100644 --- a/tests/Utils/Directives/GeocodeDirective.php +++ b/tests/Utils/Directives/GeocodeDirective.php @@ -28,7 +28,7 @@ public function runBeforeSave(Model $model): bool public function __invoke($model, $args): void { $address = $args->toArray(); - $model->setAttribute('latitude', $address['lat'] ?? 0.0); - $model->setAttribute('longitude', $address['lng'] ?? 0.0); + $model->setAttribute('latitude', $address['lat']); + $model->setAttribute('longitude', $address['lng']); } } From 1d67c85d04552ba11e722b9793778d9c816bff34 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 30 Jun 2026 11:32:31 +0200 Subject: [PATCH 28/47] Use double columns for geocode test precision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit float (single-precision) rounds 48.1351 → 48.14 on MySQL, causing assertion failures in CI. 🤖 Generated with Claude Code --- .../2026_06_30_000000_add_geocode_columns_to_users_table.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/database/migrations/2026_06_30_000000_add_geocode_columns_to_users_table.php b/tests/database/migrations/2026_06_30_000000_add_geocode_columns_to_users_table.php index 40e3364af8..8df643b6c3 100644 --- a/tests/database/migrations/2026_06_30_000000_add_geocode_columns_to_users_table.php +++ b/tests/database/migrations/2026_06_30_000000_add_geocode_columns_to_users_table.php @@ -8,8 +8,8 @@ public function up(): void { Schema::table('users', function (Blueprint $table): void { - $table->float('latitude')->nullable(); - $table->float('longitude')->nullable(); + $table->double('latitude')->nullable(); + $table->double('longitude')->nullable(); }); } From 1a7b0cdcb4e638c28fedefbbf4b6c12a7c69ddfa Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 30 Jun 2026 12:10:12 +0200 Subject: [PATCH 29/47] Guard GeocodeDirective against null args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/nuwave/lighthouse/pull/2777#discussion_r3497889759 🤖 Generated with Claude Code --- tests/Utils/Directives/GeocodeDirective.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Utils/Directives/GeocodeDirective.php b/tests/Utils/Directives/GeocodeDirective.php index 440f739ea0..1f3b3cb2a8 100644 --- a/tests/Utils/Directives/GeocodeDirective.php +++ b/tests/Utils/Directives/GeocodeDirective.php @@ -27,6 +27,10 @@ public function runBeforeSave(Model $model): bool */ public function __invoke($model, $args): void { + if ($args === null) { + return; + } + $address = $args->toArray(); $model->setAttribute('latitude', $address['lat']); $model->setAttribute('longitude', $address['lng']); From 9a19afb8016bc5ea4d7e45b88f7107c1f86983ef Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 30 Jun 2026 12:10:55 +0200 Subject: [PATCH 30/47] Document MorphTo design decision in runBeforeSave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/nuwave/lighthouse/pull/2777#discussion_r3497889803 🤖 Generated with Claude Code --- src/Schema/Directives/ModelMutationDirective.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Schema/Directives/ModelMutationDirective.php b/src/Schema/Directives/ModelMutationDirective.php index d61b1999a9..e0b0d80e8b 100644 --- a/src/Schema/Directives/ModelMutationDirective.php +++ b/src/Schema/Directives/ModelMutationDirective.php @@ -27,6 +27,7 @@ protected function relationName(): string ); } + /** Includes MorphTo (a BelongsTo subclass) — explicit directives shadow implicit relation detection. */ public function runBeforeSave(Model $model): bool { return ArgPartitioner::methodReturnsRelation( From 47373bfe5aab26a32a5a554b309095513d181c25 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 30 Jun 2026 12:20:29 +0200 Subject: [PATCH 31/47] Add null to GeocodeDirective PHPDoc for PHPStan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- tests/Utils/Directives/GeocodeDirective.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Utils/Directives/GeocodeDirective.php b/tests/Utils/Directives/GeocodeDirective.php index 1f3b3cb2a8..6cae6c31eb 100644 --- a/tests/Utils/Directives/GeocodeDirective.php +++ b/tests/Utils/Directives/GeocodeDirective.php @@ -23,7 +23,7 @@ public function runBeforeSave(Model $model): bool /** * @param Model $model - * @param ArgumentSet $args + * @param ArgumentSet|null $args */ public function __invoke($model, $args): void { From fa01d28a676c5c95f7726ffd8e6b8d2cf069eb28 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 30 Jun 2026 15:02:57 +0200 Subject: [PATCH 32/47] Fix SaveAwareArgResolver inside @nest being silently skipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the pre-save filtering from nestedArgResolvers() into a separate nestedArgResolversWithoutPreSave() method used only by ModelMutationDirective. This keeps nestedArgResolvers() generic so @nest (and other contexts) include all resolvers in the nested set. Additionally, NestDirective now saves the model when dirty after resolving, so attribute-setting resolvers like @geocode work correctly under @nest. 🤖 Generated with Claude Code --- src/Execution/Arguments/ArgPartitioner.php | 31 +++++++++- .../Directives/ModelMutationDirective.php | 5 +- src/Schema/Directives/NestDirective.php | 11 +++- .../Contracts/SaveAwareArgResolver.php | 4 +- .../Schema/Directives/CreateDirectiveTest.php | 57 +++++++++++++++++++ .../Arguments/ArgPartitionerTest.php | 2 +- 6 files changed, 102 insertions(+), 8 deletions(-) diff --git a/src/Execution/Arguments/ArgPartitioner.php b/src/Execution/Arguments/ArgPartitioner.php index 12000f65dc..06a3d2e50c 100644 --- a/src/Execution/Arguments/ArgPartitioner.php +++ b/src/Execution/Arguments/ArgPartitioner.php @@ -17,9 +17,7 @@ class ArgPartitioner { /** - * Partition the arguments into nested (post-save) and regular. - * - * Resolvers implementing SaveAwareArgResolver that return true from runBeforeSave() are excluded from the nested set when the root is a Model, allowing SaveModel to handle them before persisting. + * Partition the arguments into nested and regular. * * @return array{ * 0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, @@ -36,6 +34,33 @@ public static function nestedArgResolvers(ArgumentSet $argumentSet, mixed $root) static::attachNestedArgResolver($name, $argument, $model); } + return static::partition( + $argumentSet, + static fn (string $name, Argument $argument): bool => isset($argument->resolver), + ); + } + + /** + * Like nestedArgResolvers(), but excludes SaveAwareArgResolvers that run before save. + * + * Used by SaveModel's ResolveNested wrapper so pre-save resolvers stay in the + * regular set and reach SaveModel for execution before $model->save(). + * + * @return array{ + * 0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, + * 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, + * } + */ + public static function nestedArgResolversWithoutPreSave(ArgumentSet $argumentSet, mixed $root): array + { + $model = $root instanceof Model + ? new \ReflectionClass($root) + : null; + + foreach ($argumentSet->arguments as $name => $argument) { + static::attachNestedArgResolver($name, $argument, $model); + } + return static::partition( $argumentSet, static function (string $name, Argument $argument) use ($root): bool { diff --git a/src/Schema/Directives/ModelMutationDirective.php b/src/Schema/Directives/ModelMutationDirective.php index e0b0d80e8b..0d534b9674 100644 --- a/src/Schema/Directives/ModelMutationDirective.php +++ b/src/Schema/Directives/ModelMutationDirective.php @@ -65,7 +65,10 @@ public function __invoke($model, $args): mixed */ protected function executeMutation(Model $model, ArgumentSet|array $args, ?Relation $parentRelation = null): Model|array { - $update = new ResolveNested($this->makeExecutionFunction($parentRelation)); + $update = new ResolveNested( + $this->makeExecutionFunction($parentRelation), + [ArgPartitioner::class, 'nestedArgResolversWithoutPreSave'], + ); return Utils::mapEach( static fn (ArgumentSet $argumentSet): mixed => $update($model->newInstance(), $argumentSet), diff --git a/src/Schema/Directives/NestDirective.php b/src/Schema/Directives/NestDirective.php index ef3e1b1cce..c17e091b07 100644 --- a/src/Schema/Directives/NestDirective.php +++ b/src/Schema/Directives/NestDirective.php @@ -2,6 +2,7 @@ namespace Nuwave\Lighthouse\Schema\Directives; +use Illuminate\Database\Eloquent\Model; use Nuwave\Lighthouse\Execution\Arguments\ArgumentSet; use Nuwave\Lighthouse\Execution\Arguments\ResolveNested; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; @@ -30,7 +31,15 @@ public function __invoke(mixed $root, $args): mixed $resolveNested = new ResolveNested(); return Utils::mapEach( - static fn (ArgumentSet $argumentSet): mixed => $resolveNested($root, $argumentSet), + static function (ArgumentSet $argumentSet) use ($resolveNested, $root): mixed { + $result = $resolveNested($root, $argumentSet); + + if ($root instanceof Model && $root->isDirty()) { + $root->save(); + } + + return $result; + }, $args, ); } diff --git a/src/Support/Contracts/SaveAwareArgResolver.php b/src/Support/Contracts/SaveAwareArgResolver.php index 0d266a1f40..8c872b3f26 100644 --- a/src/Support/Contracts/SaveAwareArgResolver.php +++ b/src/Support/Contracts/SaveAwareArgResolver.php @@ -20,8 +20,8 @@ interface SaveAwareArgResolver extends ArgResolver * When false, the resolver runs after the model is saved (the default * for any ArgResolver that does not implement this interface). * - * Only consulted when the root is a Model inside SaveModel's orchestration. - * In non-Model contexts (e.g. @nest), this method is not called and the resolver runs in the default post-save position. + * Only consulted when the root is a Model. + * In non-Model contexts, this method is not called and the resolver executes normally. */ public function runBeforeSave(Model $model): bool; } diff --git a/tests/Integration/Schema/Directives/CreateDirectiveTest.php b/tests/Integration/Schema/Directives/CreateDirectiveTest.php index 685e662a66..e328c62b80 100644 --- a/tests/Integration/Schema/Directives/CreateDirectiveTest.php +++ b/tests/Integration/Schema/Directives/CreateDirectiveTest.php @@ -811,4 +811,61 @@ public function testGeocodePreSaveArgResolver(): void ], ]); } + + public function testGeocodePreSaveArgResolverInsideNest(): void + { + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type User { + id: ID! + name: String! + latitude: Float + longitude: Float + } + + type Mutation { + createUser(input: CreateUserInput! @spread): User @create + } + + input CreateUserInput { + name: String! + nested: NestedInput @nest + } + + input NestedInput { + location: LocationInput @geocode + } + + input LocationInput { + lat: Float! + lng: Float! + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createUser(input: { + name: "Nested Geo User" + nested: { + location: { + lat: 48.1351 + lng: 11.5820 + } + } + }) { + id + name + latitude + longitude + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createUser' => [ + 'name' => 'Nested Geo User', + 'latitude' => 48.1351, + 'longitude' => 11.582, + ], + ], + ]); + } } diff --git a/tests/Unit/Execution/Arguments/ArgPartitionerTest.php b/tests/Unit/Execution/Arguments/ArgPartitionerTest.php index 62aa900d6c..e066fb3d23 100644 --- a/tests/Unit/Execution/Arguments/ArgPartitionerTest.php +++ b/tests/Unit/Execution/Arguments/ArgPartitionerTest.php @@ -149,7 +149,7 @@ public function testSaveAwareArgResolverWithModelRoot(): void $saveAware->directives->push(new SaveAwareNested()); $argumentSet->arguments['saveAware'] = $saveAware; - [$nestedArgs, $regularArgs] = ArgPartitioner::nestedArgResolvers($argumentSet, new User()); + [$nestedArgs, $regularArgs] = ArgPartitioner::nestedArgResolversWithoutPreSave($argumentSet, new User()); $this->assertSame( ['regular' => $regular, 'saveAware' => $saveAware], From 1a7fc75eac2c199dff6b1bbff2aef4248e7c0ba5 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 30 Jun 2026 15:53:29 +0200 Subject: [PATCH 33/47] Clarify runBeforeSave contract: class-based, not instance-dependent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- src/Execution/Arguments/ArgPartitioner.php | 2 +- src/Support/Contracts/SaveAwareArgResolver.php | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Execution/Arguments/ArgPartitioner.php b/src/Execution/Arguments/ArgPartitioner.php index 06a3d2e50c..0438f78667 100644 --- a/src/Execution/Arguments/ArgPartitioner.php +++ b/src/Execution/Arguments/ArgPartitioner.php @@ -75,7 +75,7 @@ static function (string $name, Argument $argument) use ($root): bool { } /** - * Requires that resolvers have been attached via nestedArgResolvers() first. + * Requires that attachNestedArgResolver() has run on the arguments first. * * @return array{ * 0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, diff --git a/src/Support/Contracts/SaveAwareArgResolver.php b/src/Support/Contracts/SaveAwareArgResolver.php index 8c872b3f26..d8a1ef3465 100644 --- a/src/Support/Contracts/SaveAwareArgResolver.php +++ b/src/Support/Contracts/SaveAwareArgResolver.php @@ -22,6 +22,9 @@ interface SaveAwareArgResolver extends ArgResolver * * Only consulted when the root is a Model. * In non-Model contexts, this method is not called and the resolver executes normally. + * + * Implementations must base the decision on the model class and its relations, + * not on instance state — the model may not yet be hydrated when this is called. */ public function runBeforeSave(Model $model): bool; } From 0fefe354a9a96d7b24cc6ed5b9d3a92e7a669379 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Wed, 1 Jul 2026 08:45:23 +0200 Subject: [PATCH 34/47] Address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make @nest transparent for pre-save resolution: instead of @nest saving dirty models itself, ArgPartitioner lifts pre-save resolvers out of @nest children so they run in SaveModel before persist. https://github.com/nuwave/lighthouse/pull/2777 🤖 Generated with Claude Code --- src/Execution/Arguments/ArgPartitioner.php | 50 +++++++++++++++++-- .../Directives/ModelMutationDirective.php | 2 +- src/Schema/Directives/NestDirective.php | 11 +--- .../Schema/Directives/CreateDirectiveTest.php | 24 +++++---- tests/Utils/Models/User.php | 2 + ...28_000002_create_testbench_users_table.php | 2 + ...000_add_geocode_columns_to_users_table.php | 22 -------- 7 files changed, 66 insertions(+), 47 deletions(-) delete mode 100644 tests/database/migrations/2026_06_30_000000_add_geocode_columns_to_users_table.php diff --git a/src/Execution/Arguments/ArgPartitioner.php b/src/Execution/Arguments/ArgPartitioner.php index 0438f78667..90e8e139a6 100644 --- a/src/Execution/Arguments/ArgPartitioner.php +++ b/src/Execution/Arguments/ArgPartitioner.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\Relations\MorphToMany; use Nuwave\Lighthouse\Exceptions\DefinitionException; +use Nuwave\Lighthouse\Schema\Directives\NestDirective; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; use Nuwave\Lighthouse\Support\Contracts\SaveAwareArgResolver; use Nuwave\Lighthouse\Support\Utils; @@ -61,17 +62,56 @@ public static function nestedArgResolversWithoutPreSave(ArgumentSet $argumentSet static::attachNestedArgResolver($name, $argument, $model); } - return static::partition( + [$nested, $regular] = static::partition( $argumentSet, - static function (string $name, Argument $argument) use ($root): bool { + static function (string $name, Argument $argument) use ($root, $model): bool { $resolver = $argument->resolver; - if ($resolver === null) { - return false; + if ($resolver === null || $model === null) { + return $resolver !== null; } - return ! ($resolver instanceof SaveAwareArgResolver && $root instanceof Model && $resolver->runBeforeSave($root)); + assert($root instanceof Model); + + return ! ($resolver instanceof SaveAwareArgResolver && $resolver->runBeforeSave($root)); }, ); + + if ($root instanceof Model) { + static::liftPreSaveResolversFromNest($nested, $regular, $root, $model); + } + + return [$nested, $regular]; + } + + /** + * Traverse through @nest arguments and lift pre-save resolvers to the regular set + * so they reach SaveModel and execute before $model->save(). + * + * @param \ReflectionClass<\Illuminate\Database\Eloquent\Model>|null $model + */ + protected static function liftPreSaveResolversFromNest(ArgumentSet $nested, ArgumentSet $regular, Model $root, ?\ReflectionClass $model): void + { + foreach ($nested->arguments as $argument) { + if (! ($argument->resolver instanceof NestDirective)) { + continue; + } + + $nestValue = $argument->value; + if (! ($nestValue instanceof ArgumentSet)) { + continue; + } + + foreach ($nestValue->arguments as $childName => $childArgument) { + static::attachNestedArgResolver($childName, $childArgument, $model); + } + + foreach ($nestValue->arguments as $childName => $childArgument) { + if ($childArgument->resolver instanceof SaveAwareArgResolver && $childArgument->resolver->runBeforeSave($root)) { + $regular->arguments[$childName] = $childArgument; + unset($nestValue->arguments[$childName]); + } + } + } } /** diff --git a/src/Schema/Directives/ModelMutationDirective.php b/src/Schema/Directives/ModelMutationDirective.php index 0d534b9674..8c0b126148 100644 --- a/src/Schema/Directives/ModelMutationDirective.php +++ b/src/Schema/Directives/ModelMutationDirective.php @@ -27,12 +27,12 @@ protected function relationName(): string ); } - /** Includes MorphTo (a BelongsTo subclass) — explicit directives shadow implicit relation detection. */ public function runBeforeSave(Model $model): bool { return ArgPartitioner::methodReturnsRelation( new \ReflectionClass($model), $this->relationName(), + // Includes MorphTo (a BelongsTo subclass) — explicit directives shadow implicit relation detection. BelongsTo::class, ); } diff --git a/src/Schema/Directives/NestDirective.php b/src/Schema/Directives/NestDirective.php index c17e091b07..ef3e1b1cce 100644 --- a/src/Schema/Directives/NestDirective.php +++ b/src/Schema/Directives/NestDirective.php @@ -2,7 +2,6 @@ namespace Nuwave\Lighthouse\Schema\Directives; -use Illuminate\Database\Eloquent\Model; use Nuwave\Lighthouse\Execution\Arguments\ArgumentSet; use Nuwave\Lighthouse\Execution\Arguments\ResolveNested; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; @@ -31,15 +30,7 @@ public function __invoke(mixed $root, $args): mixed $resolveNested = new ResolveNested(); return Utils::mapEach( - static function (ArgumentSet $argumentSet) use ($resolveNested, $root): mixed { - $result = $resolveNested($root, $argumentSet); - - if ($root instanceof Model && $root->isDirty()) { - $root->save(); - } - - return $result; - }, + static fn (ArgumentSet $argumentSet): mixed => $resolveNested($root, $argumentSet), $args, ); } diff --git a/tests/Integration/Schema/Directives/CreateDirectiveTest.php b/tests/Integration/Schema/Directives/CreateDirectiveTest.php index e328c62b80..c8d70e8b8f 100644 --- a/tests/Integration/Schema/Directives/CreateDirectiveTest.php +++ b/tests/Integration/Schema/Directives/CreateDirectiveTest.php @@ -585,6 +585,7 @@ public function testTurnOnMassAssignment(): void GRAPHQL); } + /** Proves before-save ordering: posts.task_id is NOT NULL, so saving without the FK set would throw. */ public function testUpsertBelongsToBeforeSave(): void { $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' @@ -676,13 +677,15 @@ public function testUpsertBelongsToTakesPrecedenceOverImplicitRelation(): void } GRAPHQL; - $this->graphQL(/** @lang GraphQL */ <<graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($id: ID!, $name: String!) { createPost(input: { title: "My post" task: { - id: "{$task->id}" - name: "Updated via directive" + id: $id + name: $name } }) { id @@ -693,20 +696,23 @@ public function testUpsertBelongsToTakesPrecedenceOverImplicitRelation(): void } } } - GRAPHQL)->assertJson([ + GRAPHQL, [ + 'id' => $task->id, + 'name' => $updatedName, + ])->assertJson([ 'data' => [ 'createPost' => [ 'title' => 'My post', 'task' => [ 'id' => (string) $task->id, - 'name' => 'Updated via directive', + 'name' => $updatedName, ], ], ], ]); $task->refresh(); - $this->assertSame('Updated via directive', $task->name); + $this->assertSame($updatedName, $task->name); } public function testUpsertBelongsToWithNullValue(): void @@ -761,7 +767,7 @@ public function testUpsertBelongsToWithNullValue(): void ]); } - public function testGeocodePreSaveArgResolver(): void + public function testCustomDirectiveSetsModelAttributesBeforeSave(): void { $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' type User { @@ -812,7 +818,7 @@ public function testGeocodePreSaveArgResolver(): void ]); } - public function testGeocodePreSaveArgResolverInsideNest(): void + public function testCustomDirectiveSetsModelAttributesBeforeSaveInsideNest(): void { $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' type User { diff --git a/tests/Utils/Models/User.php b/tests/Utils/Models/User.php index f6ad87e302..29ee58e32d 100644 --- a/tests/Utils/Models/User.php +++ b/tests/Utils/Models/User.php @@ -26,6 +26,8 @@ * @property string|null $password * @property Carbon|null $date_of_birth * @property string|null $remember_token + * @property float|null $latitude + * @property float|null $longitude * * Timestamps * @property \Illuminate\Support\Carbon $created_at diff --git a/tests/database/migrations/2018_02_28_000002_create_testbench_users_table.php b/tests/database/migrations/2018_02_28_000002_create_testbench_users_table.php index 6e5dac2d6b..44c8a6f34a 100644 --- a/tests/database/migrations/2018_02_28_000002_create_testbench_users_table.php +++ b/tests/database/migrations/2018_02_28_000002_create_testbench_users_table.php @@ -23,6 +23,8 @@ public function up(): void $table->unsignedBigInteger('team_id')->nullable(); $table->unsignedBigInteger('person_id')->nullable(); $table->string('person_type')->nullable(); + $table->double('latitude')->nullable(); + $table->double('longitude')->nullable(); }); } diff --git a/tests/database/migrations/2026_06_30_000000_add_geocode_columns_to_users_table.php b/tests/database/migrations/2026_06_30_000000_add_geocode_columns_to_users_table.php deleted file mode 100644 index 8df643b6c3..0000000000 --- a/tests/database/migrations/2026_06_30_000000_add_geocode_columns_to_users_table.php +++ /dev/null @@ -1,22 +0,0 @@ -double('latitude')->nullable(); - $table->double('longitude')->nullable(); - }); - } - - public function down(): void - { - Schema::table('users', function (Blueprint $table): void { - $table->dropColumn('latitude', 'longitude'); - }); - } -}; From 6ef90ed4bb5f34fe20fe5c7825488d273db118bc Mon Sep 17 00:00:00 2001 From: spawnia Date: Wed, 1 Jul 2026 06:49:22 +0000 Subject: [PATCH 35/47] Apply php-cs-fixer changes --- src/Execution/Arguments/ArgPartitioner.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Execution/Arguments/ArgPartitioner.php b/src/Execution/Arguments/ArgPartitioner.php index 90e8e139a6..ceacebfdff 100644 --- a/src/Execution/Arguments/ArgPartitioner.php +++ b/src/Execution/Arguments/ArgPartitioner.php @@ -92,12 +92,12 @@ static function (string $name, Argument $argument) use ($root, $model): bool { protected static function liftPreSaveResolversFromNest(ArgumentSet $nested, ArgumentSet $regular, Model $root, ?\ReflectionClass $model): void { foreach ($nested->arguments as $argument) { - if (! ($argument->resolver instanceof NestDirective)) { + if (! $argument->resolver instanceof NestDirective) { continue; } $nestValue = $argument->value; - if (! ($nestValue instanceof ArgumentSet)) { + if (! $nestValue instanceof ArgumentSet) { continue; } From bc1345a1bc7f77a113628819fc31b1f43d84bd56 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Wed, 1 Jul 2026 09:12:06 +0200 Subject: [PATCH 36/47] Make NestDirective a pure marker, handle recursion in ResolveNested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NestDirective no longer contains resolution logic — it only serves as a marker for argument partitioning. ResolveNested detects it and recurses into the nested ArgumentSet directly. 🤖 Generated with Claude Code --- src/Execution/Arguments/ResolveNested.php | 13 +++++++++++-- src/Schema/Directives/NestDirective.php | 21 ++++++--------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/Execution/Arguments/ResolveNested.php b/src/Execution/Arguments/ResolveNested.php index ed1890dcaf..736d55154c 100644 --- a/src/Execution/Arguments/ResolveNested.php +++ b/src/Execution/Arguments/ResolveNested.php @@ -2,7 +2,9 @@ namespace Nuwave\Lighthouse\Execution\Arguments; +use Nuwave\Lighthouse\Schema\Directives\NestDirective; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; +use Nuwave\Lighthouse\Support\Utils; class ResolveNested implements ArgResolver { @@ -30,8 +32,15 @@ public function __invoke(mixed $root, $args): mixed } foreach ($nestedArgs->arguments as $nested) { - // @phpstan-ignore-next-line we know the resolver is there because we partitioned for it - ($nested->resolver)($root, $nested->value); + if ($nested->resolver instanceof NestDirective) { + Utils::mapEach( + fn (ArgumentSet $argumentSet): mixed => $this($root, $argumentSet), + $nested->value, + ); + } else { + // @phpstan-ignore-next-line we know the resolver is there because we partitioned for it + ($nested->resolver)($root, $nested->value); + } } return $root; diff --git a/src/Schema/Directives/NestDirective.php b/src/Schema/Directives/NestDirective.php index ef3e1b1cce..7073e3b017 100644 --- a/src/Schema/Directives/NestDirective.php +++ b/src/Schema/Directives/NestDirective.php @@ -2,11 +2,11 @@ namespace Nuwave\Lighthouse\Schema\Directives; -use Nuwave\Lighthouse\Execution\Arguments\ArgumentSet; -use Nuwave\Lighthouse\Execution\Arguments\ResolveNested; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; -use Nuwave\Lighthouse\Support\Utils; +/** + * Marker for nested input grouping — resolution is handled by ResolveNested. + */ class NestDirective extends BaseDirective implements ArgResolver { public static function definition(): string @@ -20,18 +20,9 @@ public static function definition(): string GRAPHQL; } - /** - * Delegate to nested arg resolvers. - * - * @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet|array<\Nuwave\Lighthouse\Execution\Arguments\ArgumentSet> $args the slice of arguments that belongs to this nested resolver - */ - public function __invoke(mixed $root, $args): mixed + /** Handled by ResolveNested — direct invocation is not supported. */ + public function __invoke(mixed $root, mixed $value): never { - $resolveNested = new ResolveNested(); - - return Utils::mapEach( - static fn (ArgumentSet $argumentSet): mixed => $resolveNested($root, $argumentSet), - $args, - ); + throw new \LogicException('NestDirective must not be invoked directly, use ResolveNested.'); } } From 296eac9a40d0eac68daf91586161e17c18e519ac Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Wed, 1 Jul 2026 09:14:59 +0200 Subject: [PATCH 37/47] Test @nest interaction with belongsTo, mixed pre/post-save, and double nesting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- .../Schema/Directives/NestDirectiveTest.php | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) diff --git a/tests/Integration/Schema/Directives/NestDirectiveTest.php b/tests/Integration/Schema/Directives/NestDirectiveTest.php index 34e6313258..7f4824cc0e 100644 --- a/tests/Integration/Schema/Directives/NestDirectiveTest.php +++ b/tests/Integration/Schema/Directives/NestDirectiveTest.php @@ -3,6 +3,7 @@ namespace Tests\Integration\Schema\Directives; use Tests\DBTestCase; +use Tests\Utils\Models\Task; final class NestDirectiveTest extends DBTestCase { @@ -63,4 +64,236 @@ public function testNestDelegates(): void ], ]); } + + public function testNestWithBelongsToUpsert(): void + { + $task = new Task(); + $task->name = 'Existing task'; + $task->save(); + + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + createPost(input: CreatePostInput! @spread): Post @create + } + + input CreatePostInput { + title: String! + nested: NestedPostInput @nest + } + + input NestedPostInput { + task: UpsertTaskInput @upsert + } + + input UpsertTaskInput { + id: ID + name: String! + } + + type Post { + id: ID! + title: String! + task: Task @belongsTo + } + + type Task { + id: ID! + name: String! + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($taskId: ID!) { + createPost(input: { + title: "Post with nested belongsTo" + nested: { + task: { + id: $taskId + name: "Updated task" + } + } + }) { + id + title + task { + id + name + } + } + } + GRAPHQL, [ + 'taskId' => $task->id, + ])->assertJson([ + 'data' => [ + 'createPost' => [ + 'title' => 'Post with nested belongsTo', + 'task' => [ + 'id' => (string) $task->id, + 'name' => 'Updated task', + ], + ], + ], + ]); + } + + /** Proves pre-save and post-save resolvers coexist inside a single @nest. */ + public function testNestWithPreSaveAndPostSaveChildren(): void + { + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + createUser(input: CreateUserInput! @spread): User @create + } + + input CreateUserInput { + name: String! + nested: NestedUserInput @nest + } + + input NestedUserInput { + location: LocationInput @geocode + newTask: CreateTaskInput @create(relation: "tasks") + } + + input LocationInput { + lat: Float! + lng: Float! + } + + input CreateTaskInput { + name: String! + } + + type User { + id: ID! + name: String! + latitude: Float + longitude: Float + tasks: [Task!]! @hasMany + } + + type Task { + id: ID! + name: String! + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createUser(input: { + name: "Mixed User" + nested: { + location: { + lat: 48.1351 + lng: 11.5820 + } + newTask: { + name: "Post-save task" + } + } + }) { + id + name + latitude + longitude + tasks { + name + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createUser' => [ + 'name' => 'Mixed User', + 'latitude' => 48.1351, + 'longitude' => 11.582, + 'tasks' => [ + ['name' => 'Post-save task'], + ], + ], + ], + ]); + } + + public function testDoubleNestedNest(): void + { + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + createUser(input: CreateUserInput! @spread): User @create + } + + input CreateUserInput { + name: String! + outer: OuterInput @nest + } + + input OuterInput { + inner: InnerInput @nest + } + + input InnerInput { + location: LocationInput @geocode + newTask: CreateTaskInput @create(relation: "tasks") + } + + input LocationInput { + lat: Float! + lng: Float! + } + + input CreateTaskInput { + name: String! + } + + type User { + id: ID! + name: String! + latitude: Float + longitude: Float + tasks: [Task!]! @hasMany + } + + type Task { + id: ID! + name: String! + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createUser(input: { + name: "Deep Nested" + outer: { + inner: { + location: { + lat: 52.5200 + lng: 13.4050 + } + newTask: { + name: "Deeply nested task" + } + } + } + }) { + id + name + latitude + longitude + tasks { + name + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createUser' => [ + 'name' => 'Deep Nested', + 'latitude' => 52.52, + 'longitude' => 13.405, + 'tasks' => [ + ['name' => 'Deeply nested task'], + ], + ], + ], + ]); + } } From adcba1d8f405611357357883a22e0fc1f1697a66 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Wed, 1 Jul 2026 11:23:25 +0200 Subject: [PATCH 38/47] Fix double-save when ResolveNested recurses into @nest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResolveNested carried $this->previous (SaveModel) into @nest recursion, causing $model->save() to fire at every nesting level. Fix by creating a new ResolveNested(null, ...) for @nest traversal. Also makes liftPreSaveResolversFromNest recursive so pre-save resolvers at any @nest depth are lifted to the outermost level, simplifies the partition predicate into separate guard clauses, merges the two foreach loops, and documents nullable $value for SaveAwareArgResolver. 🤖 Generated with Claude Code --- src/Execution/Arguments/ArgPartitioner.php | 29 ++-- src/Execution/Arguments/ResolveNested.php | 3 +- .../Contracts/SaveAwareArgResolver.php | 3 + .../Schema/Directives/NestDirectiveTest.php | 137 ++++++++++++++++++ 4 files changed, 162 insertions(+), 10 deletions(-) diff --git a/src/Execution/Arguments/ArgPartitioner.php b/src/Execution/Arguments/ArgPartitioner.php index 90e8e139a6..b558267224 100644 --- a/src/Execution/Arguments/ArgPartitioner.php +++ b/src/Execution/Arguments/ArgPartitioner.php @@ -66,17 +66,26 @@ public static function nestedArgResolversWithoutPreSave(ArgumentSet $argumentSet $argumentSet, static function (string $name, Argument $argument) use ($root, $model): bool { $resolver = $argument->resolver; - if ($resolver === null || $model === null) { - return $resolver !== null; + if ($resolver === null) { + return false; + } + + if ($model === null) { + return true; } assert($root instanceof Model); - return ! ($resolver instanceof SaveAwareArgResolver && $resolver->runBeforeSave($root)); + if ($resolver instanceof SaveAwareArgResolver) { + return ! $resolver->runBeforeSave($root); + } + + return true; }, ); - if ($root instanceof Model) { + if ($model !== null) { + assert($root instanceof Model); static::liftPreSaveResolversFromNest($nested, $regular, $root, $model); } @@ -84,12 +93,12 @@ static function (string $name, Argument $argument) use ($root, $model): bool { } /** - * Traverse through @nest arguments and lift pre-save resolvers to the regular set + * Recursively traverse @nest arguments and lift pre-save resolvers to the regular set * so they reach SaveModel and execute before $model->save(). * - * @param \ReflectionClass<\Illuminate\Database\Eloquent\Model>|null $model + * @param \ReflectionClass<\Illuminate\Database\Eloquent\Model> $model */ - protected static function liftPreSaveResolversFromNest(ArgumentSet $nested, ArgumentSet $regular, Model $root, ?\ReflectionClass $model): void + protected static function liftPreSaveResolversFromNest(ArgumentSet $nested, ArgumentSet $regular, Model $root, \ReflectionClass $model): void { foreach ($nested->arguments as $argument) { if (! ($argument->resolver instanceof NestDirective)) { @@ -103,12 +112,14 @@ protected static function liftPreSaveResolversFromNest(ArgumentSet $nested, Argu foreach ($nestValue->arguments as $childName => $childArgument) { static::attachNestedArgResolver($childName, $childArgument, $model); - } - foreach ($nestValue->arguments as $childName => $childArgument) { if ($childArgument->resolver instanceof SaveAwareArgResolver && $childArgument->resolver->runBeforeSave($root)) { $regular->arguments[$childName] = $childArgument; unset($nestValue->arguments[$childName]); + } elseif ($childArgument->resolver instanceof NestDirective) { + $childNested = new ArgumentSet(); + $childNested->arguments[$childName] = $childArgument; + static::liftPreSaveResolversFromNest($childNested, $regular, $root, $model); } } } diff --git a/src/Execution/Arguments/ResolveNested.php b/src/Execution/Arguments/ResolveNested.php index 736d55154c..cfbb66d432 100644 --- a/src/Execution/Arguments/ResolveNested.php +++ b/src/Execution/Arguments/ResolveNested.php @@ -33,8 +33,9 @@ public function __invoke(mixed $root, $args): mixed foreach ($nestedArgs->arguments as $nested) { if ($nested->resolver instanceof NestDirective) { + $nestResolver = new self(null, $this->argPartitioner); Utils::mapEach( - fn (ArgumentSet $argumentSet): mixed => $this($root, $argumentSet), + fn (ArgumentSet $argumentSet): mixed => $nestResolver($root, $argumentSet), $nested->value, ); } else { diff --git a/src/Support/Contracts/SaveAwareArgResolver.php b/src/Support/Contracts/SaveAwareArgResolver.php index d8a1ef3465..c4f907da01 100644 --- a/src/Support/Contracts/SaveAwareArgResolver.php +++ b/src/Support/Contracts/SaveAwareArgResolver.php @@ -8,6 +8,9 @@ * Implement this on ArgResolver directives that need to control whether they * run before or after the parent model is saved during mutation execution. * + * When `runBeforeSave()` returns true, `__invoke()` receives null as `$value` + * if the client sends null for a nullable input field. Guard accordingly. + * * @api */ interface SaveAwareArgResolver extends ArgResolver diff --git a/tests/Integration/Schema/Directives/NestDirectiveTest.php b/tests/Integration/Schema/Directives/NestDirectiveTest.php index 7f4824cc0e..75d2349f6b 100644 --- a/tests/Integration/Schema/Directives/NestDirectiveTest.php +++ b/tests/Integration/Schema/Directives/NestDirectiveTest.php @@ -4,6 +4,7 @@ use Tests\DBTestCase; use Tests\Utils\Models\Task; +use Tests\Utils\Models\User; final class NestDirectiveTest extends DBTestCase { @@ -214,6 +215,142 @@ public function testNestWithPreSaveAndPostSaveChildren(): void ]); } + public function testNestDoesNotSaveParentModelMultipleTimes(): void + { + $savingCount = 0; + User::saving(function () use (&$savingCount): void { + ++$savingCount; + }); + + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + createUser(input: CreateUserInput! @spread): User @create + } + + input CreateUserInput { + name: String! + nested: NestedUserInput @nest + } + + input NestedUserInput { + newTask: CreateTaskInput @create(relation: "tasks") + } + + input CreateTaskInput { + name: String! + } + + type User { + id: ID! + name: String! + tasks: [Task!]! @hasMany + } + + type Task { + id: ID! + name: String! + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createUser(input: { + name: "Save Once" + nested: { + newTask: { + name: "Post-save task" + } + } + }) { + id + name + tasks { + name + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createUser' => [ + 'name' => 'Save Once', + 'tasks' => [ + ['name' => 'Post-save task'], + ], + ], + ], + ]); + + $this->assertSame(1, $savingCount); + } + + public function testSiblingNestBlocksWithSameChildName(): void + { + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + createUser(input: CreateUserInput! @spread): User @create + } + + input CreateUserInput { + name: String! + alpha: AlphaInput @nest + beta: BetaInput @nest + } + + input AlphaInput { + location: LocationInput @geocode + } + + input BetaInput { + location: LocationInput @geocode + } + + input LocationInput { + lat: Float! + lng: Float! + } + + type User { + id: ID! + name: String! + latitude: Float + longitude: Float + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createUser(input: { + name: "Sibling Nest" + alpha: { + location: { + lat: 48.0 + lng: 11.0 + } + } + beta: { + location: { + lat: 52.0 + lng: 13.0 + } + } + }) { + id + name + latitude + longitude + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createUser' => [ + 'name' => 'Sibling Nest', + 'latitude' => 52.0, + 'longitude' => 13.0, + ], + ], + ]); + } + public function testDoubleNestedNest(): void { $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' From 50d97c5ddfbde48e5971d7318cba72b5764a267d Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Wed, 1 Jul 2026 11:28:11 +0200 Subject: [PATCH 39/47] Add missing test coverage for edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Unit test for nestedArgResolversWithoutPreSave with non-Model root - DB assertion in testUpsertBelongsToWithNullValue proving no User created 🤖 Generated with Claude Code --- .../Schema/Directives/CreateDirectiveTest.php | 2 ++ .../Arguments/ArgPartitionerTest.php | 25 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/tests/Integration/Schema/Directives/CreateDirectiveTest.php b/tests/Integration/Schema/Directives/CreateDirectiveTest.php index c8d70e8b8f..e484b269db 100644 --- a/tests/Integration/Schema/Directives/CreateDirectiveTest.php +++ b/tests/Integration/Schema/Directives/CreateDirectiveTest.php @@ -765,6 +765,8 @@ public function testUpsertBelongsToWithNullValue(): void ], ], ]); + + $this->assertDatabaseCount('users', 0); } public function testCustomDirectiveSetsModelAttributesBeforeSave(): void diff --git a/tests/Unit/Execution/Arguments/ArgPartitionerTest.php b/tests/Unit/Execution/Arguments/ArgPartitionerTest.php index e066fb3d23..581cf9a894 100644 --- a/tests/Unit/Execution/Arguments/ArgPartitionerTest.php +++ b/tests/Unit/Execution/Arguments/ArgPartitionerTest.php @@ -138,6 +138,31 @@ public function testSaveAwareArgResolverWithNonModelRoot(): void ); } + public function testSaveAwareArgResolverWithNonModelRootInWithoutPreSave(): void + { + $argumentSet = new ArgumentSet(); + + $regular = new Argument(); + $argumentSet->arguments['regular'] = $regular; + + $saveAware = new Argument(); + $saveAware->directives->push(new SaveAwareNested()); + $argumentSet->arguments['saveAware'] = $saveAware; + + [$nestedArgs, $regularArgs] = ArgPartitioner::nestedArgResolversWithoutPreSave($argumentSet, null); + + $this->assertSame( + ['regular' => $regular], + $regularArgs->arguments, + ); + + $this->assertSame( + ['saveAware' => $saveAware], + $nestedArgs->arguments, + 'SaveAwareArgResolver should be in nested set when root is not a Model', + ); + } + public function testSaveAwareArgResolverWithModelRoot(): void { $argumentSet = new ArgumentSet(); From 46f05c04c6001a27f07ac163ef837150b2f31123 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Wed, 1 Jul 2026 11:29:06 +0200 Subject: [PATCH 40/47] Apply code style fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- tests/Integration/Schema/Directives/NestDirectiveTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Integration/Schema/Directives/NestDirectiveTest.php b/tests/Integration/Schema/Directives/NestDirectiveTest.php index 75d2349f6b..1d5163cf6d 100644 --- a/tests/Integration/Schema/Directives/NestDirectiveTest.php +++ b/tests/Integration/Schema/Directives/NestDirectiveTest.php @@ -218,7 +218,7 @@ public function testNestWithPreSaveAndPostSaveChildren(): void public function testNestDoesNotSaveParentModelMultipleTimes(): void { $savingCount = 0; - User::saving(function () use (&$savingCount): void { + User::saving(static function () use (&$savingCount): void { ++$savingCount; }); From 2c59109ca4d0431e64a740abb8aea65d91de0456 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Wed, 1 Jul 2026 12:11:08 +0200 Subject: [PATCH 41/47] Extract shouldRunBeforeSave() and flatten control flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DRY up the instanceof + runBeforeSave check that was duplicated across liftPreSaveResolversFromNest and preSaveNestedArgResolvers. Replace @phpstan-ignore-next-line with a proper assert() in ResolveNested, and use early-continue to reduce nesting. 🤖 Generated with Claude Code --- src/Execution/Arguments/ArgPartitioner.php | 18 ++++++++++++++---- src/Execution/Arguments/ResolveNested.php | 11 +++++++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/Execution/Arguments/ArgPartitioner.php b/src/Execution/Arguments/ArgPartitioner.php index 158363b517..76d9fe8f95 100644 --- a/src/Execution/Arguments/ArgPartitioner.php +++ b/src/Execution/Arguments/ArgPartitioner.php @@ -113,10 +113,15 @@ protected static function liftPreSaveResolversFromNest(ArgumentSet $nested, Argu foreach ($nestValue->arguments as $childName => $childArgument) { static::attachNestedArgResolver($childName, $childArgument, $model); - if ($childArgument->resolver instanceof SaveAwareArgResolver && $childArgument->resolver->runBeforeSave($root)) { + $resolver = $childArgument->resolver; + + if (self::shouldRunBeforeSave($resolver, $root)) { $regular->arguments[$childName] = $childArgument; unset($nestValue->arguments[$childName]); - } elseif ($childArgument->resolver instanceof NestDirective) { + continue; + } + + if ($resolver instanceof NestDirective) { $childNested = new ArgumentSet(); $childNested->arguments[$childName] = $childArgument; static::liftPreSaveResolversFromNest($childNested, $regular, $root, $model); @@ -137,8 +142,7 @@ public static function preSaveNestedArgResolvers(ArgumentSet $argumentSet, Model { return static::partition( $argumentSet, - static fn (string $name, Argument $argument): bool => $argument->resolver instanceof SaveAwareArgResolver - && $argument->resolver->runBeforeSave($model), + static fn (string $name, Argument $argument): bool => self::shouldRunBeforeSave($argument->resolver, $model), ); } @@ -301,4 +305,10 @@ public static function methodReturnsRelation( return is_a($returnType->getName(), $relationClass, true); } + + public static function shouldRunBeforeSave(?ArgResolver $resolver, Model $model): bool + { + return $resolver instanceof SaveAwareArgResolver + && $resolver->runBeforeSave($model); + } } diff --git a/src/Execution/Arguments/ResolveNested.php b/src/Execution/Arguments/ResolveNested.php index cfbb66d432..3f04afe294 100644 --- a/src/Execution/Arguments/ResolveNested.php +++ b/src/Execution/Arguments/ResolveNested.php @@ -32,16 +32,19 @@ public function __invoke(mixed $root, $args): mixed } foreach ($nestedArgs->arguments as $nested) { - if ($nested->resolver instanceof NestDirective) { + $resolver = $nested->resolver; + assert($resolver !== null, 'we know the resolver is there because we partitioned for it'); + + if ($resolver instanceof NestDirective) { $nestResolver = new self(null, $this->argPartitioner); Utils::mapEach( fn (ArgumentSet $argumentSet): mixed => $nestResolver($root, $argumentSet), $nested->value, ); - } else { - // @phpstan-ignore-next-line we know the resolver is there because we partitioned for it - ($nested->resolver)($root, $nested->value); + continue; } + + $resolver($root, $nested->value); } return $root; From eef6e102ecdf1e3f8bcf314d984b9965733e2815 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Wed, 1 Jul 2026 12:11:24 +0200 Subject: [PATCH 42/47] protected --- src/Execution/Arguments/ArgPartitioner.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Execution/Arguments/ArgPartitioner.php b/src/Execution/Arguments/ArgPartitioner.php index 76d9fe8f95..7c8c940eb0 100644 --- a/src/Execution/Arguments/ArgPartitioner.php +++ b/src/Execution/Arguments/ArgPartitioner.php @@ -306,7 +306,7 @@ public static function methodReturnsRelation( return is_a($returnType->getName(), $relationClass, true); } - public static function shouldRunBeforeSave(?ArgResolver $resolver, Model $model): bool + protected static function shouldRunBeforeSave(?ArgResolver $resolver, Model $model): bool { return $resolver instanceof SaveAwareArgResolver && $resolver->runBeforeSave($model); From f27fa6366dc64884b80111e6dc7e04aa6981ea6f Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Wed, 1 Jul 2026 13:30:02 +0200 Subject: [PATCH 43/47] Validate @nest is used on non-list input object types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schema-time validation catches misuse early (scalars, lists) instead of producing confusing runtime errors. Also handles nullable @nest receiving null at runtime gracefully by skipping resolution. https://github.com/nuwave/lighthouse/pull/2777#discussion_r3504847857 https://github.com/nuwave/lighthouse/pull/2777#discussion_r3504847889 🤖 Generated with Claude Code --- src/Execution/Arguments/ArgPartitioner.php | 4 +- src/Execution/Arguments/ResolveNested.php | 11 +- src/Schema/Directives/NestDirective.php | 61 ++++++++- .../Schema/Directives/NestDirectiveTest.php | 54 ++++++++ .../Schema/Directives/NestDirectiveTest.php | 117 ++++++++++++++++++ 5 files changed, 240 insertions(+), 7 deletions(-) create mode 100644 tests/Unit/Schema/Directives/NestDirectiveTest.php diff --git a/src/Execution/Arguments/ArgPartitioner.php b/src/Execution/Arguments/ArgPartitioner.php index 7c8c940eb0..501c59d93e 100644 --- a/src/Execution/Arguments/ArgPartitioner.php +++ b/src/Execution/Arguments/ArgPartitioner.php @@ -106,10 +106,12 @@ protected static function liftPreSaveResolversFromNest(ArgumentSet $nested, Argu } $nestValue = $argument->value; - if (! $nestValue instanceof ArgumentSet) { + if ($nestValue === null) { continue; } + assert($nestValue instanceof ArgumentSet, 'NestDirective validates that @nest is used on non-list input object types.'); + foreach ($nestValue->arguments as $childName => $childArgument) { static::attachNestedArgResolver($childName, $childArgument, $model); diff --git a/src/Execution/Arguments/ResolveNested.php b/src/Execution/Arguments/ResolveNested.php index 3f04afe294..43e89254f1 100644 --- a/src/Execution/Arguments/ResolveNested.php +++ b/src/Execution/Arguments/ResolveNested.php @@ -4,7 +4,6 @@ use Nuwave\Lighthouse\Schema\Directives\NestDirective; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; -use Nuwave\Lighthouse\Support\Utils; class ResolveNested implements ArgResolver { @@ -36,11 +35,13 @@ public function __invoke(mixed $root, $args): mixed assert($resolver !== null, 'we know the resolver is there because we partitioned for it'); if ($resolver instanceof NestDirective) { + if ($nested->value === null) { + continue; + } + + assert($nested->value instanceof ArgumentSet, 'NestDirective validates that @nest is used on non-list input object types.'); $nestResolver = new self(null, $this->argPartitioner); - Utils::mapEach( - fn (ArgumentSet $argumentSet): mixed => $nestResolver($root, $argumentSet), - $nested->value, - ); + $nestResolver($root, $nested->value); continue; } diff --git a/src/Schema/Directives/NestDirective.php b/src/Schema/Directives/NestDirective.php index 7073e3b017..2506c75889 100644 --- a/src/Schema/Directives/NestDirective.php +++ b/src/Schema/Directives/NestDirective.php @@ -2,12 +2,24 @@ 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\ListTypeNode; +use GraphQL\Language\AST\NonNullTypeNode; +use GraphQL\Language\AST\ObjectTypeDefinitionNode; +use Nuwave\Lighthouse\Exceptions\DefinitionException; +use Nuwave\Lighthouse\Schema\AST\ASTHelper; +use Nuwave\Lighthouse\Schema\AST\DocumentAST; +use Nuwave\Lighthouse\Support\Contracts\ArgManipulator; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; +use Nuwave\Lighthouse\Support\Contracts\InputFieldManipulator; /** * Marker for nested input grouping — resolution is handled by ResolveNested. */ -class NestDirective extends BaseDirective implements ArgResolver +class NestDirective extends BaseDirective implements ArgResolver, ArgManipulator, InputFieldManipulator { public static function definition(): string { @@ -25,4 +37,51 @@ public function __invoke(mixed $root, mixed $value): never { throw new \LogicException('NestDirective must not be invoked directly, use ResolveNested.'); } + + public function manipulateArgDefinition( + DocumentAST &$documentAST, + InputValueDefinitionNode &$argDefinition, + FieldDefinitionNode &$parentField, + ObjectTypeDefinitionNode|InterfaceTypeDefinitionNode &$parentType, + ): void { + $this->ensureInputObjectType( + $argDefinition, + $documentAST, + "{$parentType->name->value}.{$parentField->name->value}:{$argDefinition->name->value}", + ); + } + + public function manipulateInputFieldDefinition( + DocumentAST &$documentAST, + InputValueDefinitionNode &$inputField, + InputObjectTypeDefinitionNode &$parentInput, + ): void { + $this->ensureInputObjectType( + $inputField, + $documentAST, + "{$parentInput->name->value}.{$inputField->name->value}", + ); + } + + protected function ensureInputObjectType(InputValueDefinitionNode $definition, DocumentAST $documentAST, string $location): void + { + $type = $definition->type instanceof NonNullTypeNode + ? $definition->type->type + : $definition->type; + + if ($type instanceof ListTypeNode) { + $printedType = \GraphQL\Language\Printer::doPrint($definition->type); + + throw new DefinitionException("The @nest directive must be used on input object types, got {$printedType} on {$location}."); + } + + $typeName = ASTHelper::getUnderlyingTypeName($definition); + $typeDefinition = $documentAST->types[$typeName] ?? null; + + if (! $typeDefinition instanceof InputObjectTypeDefinitionNode) { + $printedType = \GraphQL\Language\Printer::doPrint($definition->type); + + throw new DefinitionException("The @nest directive must be used on input object types, got {$printedType} on {$location}."); + } + } } diff --git a/tests/Integration/Schema/Directives/NestDirectiveTest.php b/tests/Integration/Schema/Directives/NestDirectiveTest.php index 1d5163cf6d..4948a13a9a 100644 --- a/tests/Integration/Schema/Directives/NestDirectiveTest.php +++ b/tests/Integration/Schema/Directives/NestDirectiveTest.php @@ -351,6 +351,60 @@ public function testSiblingNestBlocksWithSameChildName(): void ]); } + public function testNullableNestWithNullValue(): void + { + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + createUser(input: CreateUserInput! @spread): User @create + } + + input CreateUserInput { + name: String! + nested: NestedUserInput @nest + } + + input NestedUserInput { + newTask: CreateTaskInput @create(relation: "tasks") + } + + input CreateTaskInput { + name: String! + } + + type User { + id: ID! + name: String! + tasks: [Task!]! @hasMany + } + + type Task { + id: ID! + name: String! + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createUser(input: { + name: "No Nest" + nested: null + }) { + name + tasks { + name + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createUser' => [ + 'name' => 'No Nest', + 'tasks' => [], + ], + ], + ]); + } + public function testDoubleNestedNest(): void { $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' diff --git a/tests/Unit/Schema/Directives/NestDirectiveTest.php b/tests/Unit/Schema/Directives/NestDirectiveTest.php new file mode 100644 index 0000000000..7962e99699 --- /dev/null +++ b/tests/Unit/Schema/Directives/NestDirectiveTest.php @@ -0,0 +1,117 @@ +expectExceptionObject(new DefinitionException('The @nest directive must be used on input object types, got String on Mutation.createUser:name.')); + $this->buildSchema(/** @lang GraphQL */ <<<'GRAPHQL' + type Query { + dummy: Int + } + + type Mutation { + createUser(name: String @nest): User @create + } + + type User { + name: String + } + GRAPHQL); + } + + public function testThrowsOnListType(): void + { + $this->expectExceptionObject(new DefinitionException('The @nest directive must be used on input object types, got [TaskInput!] on Mutation.createUser:tasks.')); + $this->buildSchema(/** @lang GraphQL */ <<<'GRAPHQL' + type Query { + dummy: Int + } + + type Mutation { + createUser(tasks: [TaskInput!] @nest): User @create + } + + input TaskInput { + name: String + } + + type User { + name: String + } + GRAPHQL); + } + + public function testThrowsOnInputFieldWithScalarType(): void + { + $this->expectExceptionObject(new DefinitionException('The @nest directive must be used on input object types, got String on CreateUserInput.name.')); + $this->buildSchema(/** @lang GraphQL */ <<<'GRAPHQL' + type Query { + dummy: Int + } + + type Mutation { + createUser(input: CreateUserInput! @spread): User @create + } + + input CreateUserInput { + name: String @nest + } + + type User { + name: String + } + GRAPHQL); + } + + public function testAllowsInputObjectType(): void + { + $this->buildSchema(/** @lang GraphQL */ <<<'GRAPHQL' + type Query { + dummy: Int + } + + type Mutation { + createUser(tasks: TaskOps @nest): User @create + } + + input TaskOps { + name: String + } + + type User { + name: String + } + GRAPHQL); + + $this->expectNotToPerformAssertions(); + } + + public function testAllowsNonNullInputObjectType(): void + { + $this->buildSchema(/** @lang GraphQL */ <<<'GRAPHQL' + type Query { + dummy: Int + } + + type Mutation { + createUser(tasks: TaskOps! @nest): User @create + } + + input TaskOps { + name: String + } + + type User { + name: String + } + GRAPHQL); + + $this->expectNotToPerformAssertions(); + } +} From 1f2b91bdf7fe13e58e307f87496e49f021eee48c Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Wed, 1 Jul 2026 14:18:59 +0200 Subject: [PATCH 44/47] extract variable --- src/Execution/Arguments/ResolveNested.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Execution/Arguments/ResolveNested.php b/src/Execution/Arguments/ResolveNested.php index 43e89254f1..83a738181c 100644 --- a/src/Execution/Arguments/ResolveNested.php +++ b/src/Execution/Arguments/ResolveNested.php @@ -34,18 +34,20 @@ public function __invoke(mixed $root, $args): mixed $resolver = $nested->resolver; assert($resolver !== null, 'we know the resolver is there because we partitioned for it'); + $value = $nested->value; if ($resolver instanceof NestDirective) { - if ($nested->value === null) { + if ($value === null) { continue; } - assert($nested->value instanceof ArgumentSet, 'NestDirective validates that @nest is used on non-list input object types.'); + assert($value instanceof ArgumentSet, 'NestDirective validates that @nest is used on non-list input object types.'); + $nestResolver = new self(null, $this->argPartitioner); - $nestResolver($root, $nested->value); + $nestResolver($root, $value); continue; } - $resolver($root, $nested->value); + $resolver($root, $value); } return $root; From c0cfe5e7fc8e11ca50cfb0d539d215a6dae38a8f Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Thu, 2 Jul 2026 09:37:27 +0200 Subject: [PATCH 45/47] Add regression tests for @nest with pre-save arg resolver changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers: multiple HasMany inside @nest, @update+@nest, argument named like a relation, @upsert on existing HasMany child, and documents last-wins semantic for sibling @nest blocks with same child name. 🤖 Generated with Claude Code --- .../Schema/Directives/NestDirectiveTest.php | 343 +++++++++++++++++- 1 file changed, 342 insertions(+), 1 deletion(-) diff --git a/tests/Integration/Schema/Directives/NestDirectiveTest.php b/tests/Integration/Schema/Directives/NestDirectiveTest.php index 4948a13a9a..18020540c7 100644 --- a/tests/Integration/Schema/Directives/NestDirectiveTest.php +++ b/tests/Integration/Schema/Directives/NestDirectiveTest.php @@ -283,7 +283,7 @@ public function testNestDoesNotSaveParentModelMultipleTimes(): void $this->assertSame(1, $savingCount); } - public function testSiblingNestBlocksWithSameChildName(): void + public function testSiblingNestBlocksWithSameChildNameLastWins(): void { $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' type Mutation { @@ -487,4 +487,345 @@ public function testDoubleNestedNest(): void ], ]); } + + /** Multiple @create(relation: "tasks") children inside one @nest block — all resolve after parent save. */ + public function testNestWithMultipleHasManyChildren(): void + { + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + createUser(input: CreateUserInput! @spread): User @create + } + + input CreateUserInput { + name: String! + operations: TaskOperationsInput @nest + } + + input TaskOperationsInput { + firstTask: CreateTaskInput @create(relation: "tasks") + secondTask: CreateTaskInput @create(relation: "tasks") + } + + input CreateTaskInput { + name: String! + } + + type User { + id: ID! + name: String! + tasks: [Task!]! @hasMany + } + + type Task { + id: ID! + name: String! + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createUser(input: { + name: "Multi-child" + operations: { + firstTask: { + name: "First task" + } + secondTask: { + name: "Second task" + } + } + }) { + id + name + tasks { + name + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createUser' => [ + 'name' => 'Multi-child', + 'tasks' => [ + ['name' => 'First task'], + ['name' => 'Second task'], + ], + ], + ], + ]); + } + + /** @update + @nest should still work — covers the argPartitioner change in ModelMutationDirective. */ + public function testUpdateWithNest(): void + { + $user = new User(); + $user->name = 'Original'; + $user->save(); + + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + updateUser(input: UpdateUserInput! @spread): User @update + } + + input UpdateUserInput { + id: ID! + nested: NestedUpdateInput @nest + } + + input NestedUpdateInput { + newTask: CreateTaskInput @create(relation: "tasks") + } + + input CreateTaskInput { + name: String! + } + + type User { + id: ID! + name: String! + tasks: [Task!]! @hasMany + } + + type Task { + id: ID! + name: String! + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($id: ID!) { + updateUser(input: { + id: $id + nested: { + newTask: { + name: "Added via update+nest" + } + } + }) { + id + name + tasks { + name + } + } + } + GRAPHQL, [ + 'id' => $user->id, + ])->assertJson([ + 'data' => [ + 'updateUser' => [ + 'id' => (string) $user->id, + 'name' => 'Original', + 'tasks' => [ + ['name' => 'Added via update+nest'], + ], + ], + ], + ]); + } + + /** Implicit BelongsTo detection still works alongside @nest children. */ + public function testImplicitBelongsToCoexistsWithNest(): void + { + $task = new Task(); + $task->name = 'Existing'; + $task->save(); + + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + createUser(input: CreateUserInput! @spread): User @create + } + + input CreateUserInput { + name: String! + tasks: TaskOps @nest + } + + input TaskOps { + newTask: CreateTaskInput @create(relation: "tasks") + } + + input CreateTaskInput { + name: String! + } + + type User { + id: ID! + name: String! + tasks: [Task!]! @hasMany + } + + type Task { + id: ID! + name: String! + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createUser(input: { + name: "Has tasks via nest" + tasks: { + newTask: { + name: "Nested task" + } + } + }) { + name + tasks { + name + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createUser' => [ + 'name' => 'Has tasks via nest', + 'tasks' => [ + ['name' => 'Nested task'], + ], + ], + ], + ]); + } + + /** @nest argument name matches a relation name on the model — must not confuse partitioner. */ + public function testNestArgumentNamedLikeRelation(): void + { + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + createUser(input: CreateUserInput! @spread): User @create + } + + input CreateUserInput { + name: String! + tasks: TaskNestInput @nest + } + + input TaskNestInput { + newTask: CreateTaskInput @create(relation: "tasks") + } + + input CreateTaskInput { + name: String! + } + + type User { + id: ID! + name: String! + tasks: [Task!]! @hasMany + } + + type Task { + id: ID! + name: String! + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createUser(input: { + name: "Ambiguous name" + tasks: { + newTask: { + name: "Created through nest" + } + } + }) { + name + tasks { + name + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createUser' => [ + 'name' => 'Ambiguous name', + 'tasks' => [ + ['name' => 'Created through nest'], + ], + ], + ], + ]); + } + + /** @upsert inside @nest for an existing HasMany child — proves existing behavior isn't broken. */ + public function testNestWithHasManyUpsertExistingChild(): void + { + $user = new User(); + $user->name = 'Parent'; + $user->save(); + + $task = new Task(); + $task->name = 'Original name'; + $task->user()->associate($user); + $task->save(); + + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Mutation { + updateUser(input: UpdateUserInput! @spread): User @update + } + + input UpdateUserInput { + id: ID! + nested: NestedInput @nest + } + + input NestedInput { + tasks: UpsertTaskInput @upsert(relation: "tasks") + } + + input UpsertTaskInput { + id: ID + name: String! + } + + type User { + id: ID! + name: String! + tasks: [Task!]! @hasMany + } + + type Task { + id: ID! + name: String! + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($userId: ID!, $taskId: ID!) { + updateUser(input: { + id: $userId + nested: { + tasks: { + id: $taskId + name: "Updated name" + } + } + }) { + id + tasks { + id + name + } + } + } + GRAPHQL, [ + 'userId' => $user->id, + 'taskId' => $task->id, + ])->assertJson([ + 'data' => [ + 'updateUser' => [ + 'id' => (string) $user->id, + 'tasks' => [ + [ + 'id' => (string) $task->id, + 'name' => 'Updated name', + ], + ], + ], + ], + ]); + } } From 5ee68054ad807e125fed0bc96a45b9d0162fb81e Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Thu, 2 Jul 2026 10:26:50 +0200 Subject: [PATCH 46/47] Extract prepareArgResolvers() to share preamble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- src/Execution/Arguments/ArgPartitioner.php | 30 ++++++++++++---------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/Execution/Arguments/ArgPartitioner.php b/src/Execution/Arguments/ArgPartitioner.php index 501c59d93e..6f77ed210a 100644 --- a/src/Execution/Arguments/ArgPartitioner.php +++ b/src/Execution/Arguments/ArgPartitioner.php @@ -27,13 +27,7 @@ class ArgPartitioner */ public static function nestedArgResolvers(ArgumentSet $argumentSet, mixed $root): array { - $model = $root instanceof Model - ? new \ReflectionClass($root) - : null; - - foreach ($argumentSet->arguments as $name => $argument) { - static::attachNestedArgResolver($name, $argument, $model); - } + static::prepareArgResolvers($argumentSet, $root); return static::partition( $argumentSet, @@ -54,13 +48,7 @@ public static function nestedArgResolvers(ArgumentSet $argumentSet, mixed $root) */ public static function nestedArgResolversWithoutPreSave(ArgumentSet $argumentSet, mixed $root): array { - $model = $root instanceof Model - ? new \ReflectionClass($root) - : null; - - foreach ($argumentSet->arguments as $name => $argument) { - static::attachNestedArgResolver($name, $argument, $model); - } + $model = static::prepareArgResolvers($argumentSet, $root); [$nested, $regular] = static::partition( $argumentSet, @@ -193,6 +181,20 @@ public static function relationMethods( return [$nonNullRelations, $remaining]; } + /** @return \ReflectionClass<\Illuminate\Database\Eloquent\Model>|null */ + protected static function prepareArgResolvers(ArgumentSet $argumentSet, mixed $root): ?\ReflectionClass + { + $model = $root instanceof Model + ? new \ReflectionClass($root) + : null; + + foreach ($argumentSet->arguments as $name => $argument) { + static::attachNestedArgResolver($name, $argument, $model); + } + + return $model; + } + /** * Attach a nested argument resolver to an argument. * From 20a97870166ab3c61d709b4b3afe8b4e2f9b28e6 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Thu, 2 Jul 2026 10:46:23 +0200 Subject: [PATCH 47/47] Add docs for SaveAwareArgResolver and @nest type constraint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- docs/master/api-reference/directives.md | 1 + .../input-value-directives.md | 47 ++++ src/Execution/Arguments/ArgPartitioner.php | 204 +++++++++--------- src/Schema/Directives/NestDirective.php | 7 +- src/Support/Contracts/ArgResolver.php | 11 +- 5 files changed, 164 insertions(+), 106 deletions(-) diff --git a/docs/master/api-reference/directives.md b/docs/master/api-reference/directives.md index 5a81b2031b..325176b945 100644 --- a/docs/master/api-reference/directives.md +++ b/docs/master/api-reference/directives.md @@ -2665,6 +2665,7 @@ directive @nest on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION ``` This may be useful to logically group arg resolvers. +Must be used on a non-list input object type. ```graphql type Mutation { diff --git a/docs/master/custom-directives/input-value-directives.md b/docs/master/custom-directives/input-value-directives.md index 0fb891b5e6..3426bc20fe 100644 --- a/docs/master/custom-directives/input-value-directives.md +++ b/docs/master/custom-directives/input-value-directives.md @@ -201,3 +201,50 @@ input DateRange { An [`Nuwave\Lighthouse\Support\Contracts\ArgResolver`](https://github.com/nuwave/lighthouse/tree/master/src/Support/Contracts/ArgResolver.php) directive allows you to compose resolvers for complex nested inputs, similar to the way that field resolvers are composed together. For an in-depth explanation of the concept of composing arg resolvers, read the [explanation of arg resolvers](../concepts/arg-resolvers.md). + +## SaveAwareArgResolver + +A [`Nuwave\Lighthouse\Support\Contracts\SaveAwareArgResolver`](https://github.com/nuwave/lighthouse/tree/master/src/Support/Contracts/SaveAwareArgResolver.php) extends `ArgResolver` and allows control over whether the resolver runs before or after the parent model is saved. + +This is useful when your directive needs to set attributes or foreign keys on the model before it is persisted. +For example, a directive that resolves a BelongsTo relationship must associate the related model before the parent is saved, since the foreign key column may have a NOT NULL constraint. + +```php +use Illuminate\Database\Eloquent\Model; +use Nuwave\Lighthouse\Execution\Arguments\ArgumentSet; +use Nuwave\Lighthouse\Schema\Directives\BaseDirective; +use Nuwave\Lighthouse\Support\Contracts\SaveAwareArgResolver; + +final class GeocodeDirective extends BaseDirective implements SaveAwareArgResolver +{ + public static function definition(): string + { + return /** @lang GraphQL */ <<<'GRAPHQL' + directive @geocode on INPUT_FIELD_DEFINITION + GRAPHQL; + } + + public function runBeforeSave(Model $model): bool + { + return true; + } + + /** @param ArgumentSet|null $args */ + public function __invoke($model, $args): void + { + if ($args === null) { + return; + } + + $address = $args->toArray(); + $model->setAttribute('latitude', $address['lat']); + $model->setAttribute('longitude', $address['lng']); + } +} +``` + +When `runBeforeSave()` returns `true`, the resolver is invoked before `$model->save()`, allowing it to set attributes on the model. +When it returns `false`, the resolver runs after the model is saved — the same timing as any `ArgResolver` that does not implement this interface. + +Note that `__invoke()` receives `null` as `$args` if the client sends `null` for a nullable input field. +Guard accordingly. diff --git a/src/Execution/Arguments/ArgPartitioner.php b/src/Execution/Arguments/ArgPartitioner.php index 6f77ed210a..e001f2c741 100644 --- a/src/Execution/Arguments/ArgPartitioner.php +++ b/src/Execution/Arguments/ArgPartitioner.php @@ -80,46 +80,6 @@ static function (string $name, Argument $argument) use ($root, $model): bool { return [$nested, $regular]; } - /** - * Recursively traverse @nest arguments and lift pre-save resolvers to the regular set - * so they reach SaveModel and execute before $model->save(). - * - * @param \ReflectionClass<\Illuminate\Database\Eloquent\Model> $model - */ - protected static function liftPreSaveResolversFromNest(ArgumentSet $nested, ArgumentSet $regular, Model $root, \ReflectionClass $model): void - { - foreach ($nested->arguments as $argument) { - if (! $argument->resolver instanceof NestDirective) { - continue; - } - - $nestValue = $argument->value; - if ($nestValue === null) { - continue; - } - - assert($nestValue instanceof ArgumentSet, 'NestDirective validates that @nest is used on non-list input object types.'); - - foreach ($nestValue->arguments as $childName => $childArgument) { - static::attachNestedArgResolver($childName, $childArgument, $model); - - $resolver = $childArgument->resolver; - - if (self::shouldRunBeforeSave($resolver, $root)) { - $regular->arguments[$childName] = $childArgument; - unset($nestValue->arguments[$childName]); - continue; - } - - if ($resolver instanceof NestDirective) { - $childNested = new ArgumentSet(); - $childNested->arguments[$childName] = $childArgument; - static::liftPreSaveResolversFromNest($childNested, $regular, $root, $model); - } - } - } - } - /** * Requires that attachNestedArgResolver() has run on the arguments first. * @@ -181,68 +141,6 @@ public static function relationMethods( return [$nonNullRelations, $remaining]; } - /** @return \ReflectionClass<\Illuminate\Database\Eloquent\Model>|null */ - protected static function prepareArgResolvers(ArgumentSet $argumentSet, mixed $root): ?\ReflectionClass - { - $model = $root instanceof Model - ? new \ReflectionClass($root) - : null; - - foreach ($argumentSet->arguments as $name => $argument) { - static::attachNestedArgResolver($name, $argument, $model); - } - - return $model; - } - - /** - * Attach a nested argument resolver to an argument. - * - * @param \ReflectionClass<\Illuminate\Database\Eloquent\Model>|null $model - */ - protected static function attachNestedArgResolver(string $name, Argument &$argument, ?\ReflectionClass $model): void - { - $resolverDirective = $argument->directives->first( - Utils::instanceofMatcher(ArgResolver::class), - ); - assert($resolverDirective instanceof ArgResolver || $resolverDirective === null); - - if ($resolverDirective !== null) { - $argument->resolver = $resolverDirective; - - return; - } - - if (isset($model)) { - $isRelation = static fn (string $relationClass): bool => static::methodReturnsRelation($model, $name, $relationClass); - - if ( - $isRelation(HasOne::class) - || $isRelation(MorphOne::class) - ) { - $argument->resolver = new ResolveNested(new NestedOneToOne($name)); - - return; - } - - if ( - $isRelation(HasMany::class) - || $isRelation(MorphMany::class) - ) { - $argument->resolver = new ResolveNested(new NestedOneToMany($name)); - - return; - } - - if ( - $isRelation(BelongsToMany::class) - || $isRelation(MorphToMany::class) - ) { - $argument->resolver = new ResolveNested(new NestedManyToMany($name)); - } - } - } - /** * Partition arguments based on a predicate. * @@ -310,9 +208,111 @@ public static function methodReturnsRelation( return is_a($returnType->getName(), $relationClass, true); } + /** + * Recursively traverse @nest arguments and lift pre-save resolvers to the regular set + * so they reach SaveModel and execute before $model->save(). + * + * @param \ReflectionClass<\Illuminate\Database\Eloquent\Model> $model + */ + protected static function liftPreSaveResolversFromNest(ArgumentSet $nested, ArgumentSet $regular, Model $root, \ReflectionClass $model): void + { + foreach ($nested->arguments as $argument) { + if (! $argument->resolver instanceof NestDirective) { + continue; + } + + $nestValue = $argument->value; + if ($nestValue === null) { + continue; + } + + assert($nestValue instanceof ArgumentSet, 'NestDirective validates that @nest is used on non-list input object types.'); + + foreach ($nestValue->arguments as $childName => $childArgument) { + static::attachNestedArgResolver($childName, $childArgument, $model); + + $resolver = $childArgument->resolver; + + if (self::shouldRunBeforeSave($resolver, $root)) { + $regular->arguments[$childName] = $childArgument; + unset($nestValue->arguments[$childName]); + continue; + } + + if ($resolver instanceof NestDirective) { + $childNested = new ArgumentSet(); + $childNested->arguments[$childName] = $childArgument; + static::liftPreSaveResolversFromNest($childNested, $regular, $root, $model); + } + } + } + } + + /** @return \ReflectionClass<\Illuminate\Database\Eloquent\Model>|null */ + protected static function prepareArgResolvers(ArgumentSet $argumentSet, mixed $root): ?\ReflectionClass + { + $model = $root instanceof Model + ? new \ReflectionClass($root) + : null; + + foreach ($argumentSet->arguments as $name => $argument) { + static::attachNestedArgResolver($name, $argument, $model); + } + + return $model; + } + protected static function shouldRunBeforeSave(?ArgResolver $resolver, Model $model): bool { return $resolver instanceof SaveAwareArgResolver && $resolver->runBeforeSave($model); } + + /** + * Attach a nested argument resolver to an argument. + * + * @param \ReflectionClass<\Illuminate\Database\Eloquent\Model>|null $model + */ + protected static function attachNestedArgResolver(string $name, Argument &$argument, ?\ReflectionClass $model): void + { + $resolverDirective = $argument->directives->first( + Utils::instanceofMatcher(ArgResolver::class), + ); + assert($resolverDirective instanceof ArgResolver || $resolverDirective === null); + + if ($resolverDirective !== null) { + $argument->resolver = $resolverDirective; + + return; + } + + if (isset($model)) { + $isRelation = static fn (string $relationClass): bool => static::methodReturnsRelation($model, $name, $relationClass); + + if ( + $isRelation(HasOne::class) + || $isRelation(MorphOne::class) + ) { + $argument->resolver = new ResolveNested(new NestedOneToOne($name)); + + return; + } + + if ( + $isRelation(HasMany::class) + || $isRelation(MorphMany::class) + ) { + $argument->resolver = new ResolveNested(new NestedOneToMany($name)); + + return; + } + + if ( + $isRelation(BelongsToMany::class) + || $isRelation(MorphToMany::class) + ) { + $argument->resolver = new ResolveNested(new NestedManyToMany($name)); + } + } + } } diff --git a/src/Schema/Directives/NestDirective.php b/src/Schema/Directives/NestDirective.php index 2506c75889..892e23f329 100644 --- a/src/Schema/Directives/NestDirective.php +++ b/src/Schema/Directives/NestDirective.php @@ -9,6 +9,7 @@ use GraphQL\Language\AST\ListTypeNode; use GraphQL\Language\AST\NonNullTypeNode; use GraphQL\Language\AST\ObjectTypeDefinitionNode; +use GraphQL\Language\Printer; use Nuwave\Lighthouse\Exceptions\DefinitionException; use Nuwave\Lighthouse\Schema\AST\ASTHelper; use Nuwave\Lighthouse\Schema\AST\DocumentAST; @@ -33,7 +34,7 @@ public static function definition(): string } /** Handled by ResolveNested — direct invocation is not supported. */ - public function __invoke(mixed $root, mixed $value): never + public function __invoke(mixed $root, mixed $value): void { throw new \LogicException('NestDirective must not be invoked directly, use ResolveNested.'); } @@ -70,7 +71,7 @@ protected function ensureInputObjectType(InputValueDefinitionNode $definition, D : $definition->type; if ($type instanceof ListTypeNode) { - $printedType = \GraphQL\Language\Printer::doPrint($definition->type); + $printedType = Printer::doPrint($definition->type); throw new DefinitionException("The @nest directive must be used on input object types, got {$printedType} on {$location}."); } @@ -79,7 +80,7 @@ protected function ensureInputObjectType(InputValueDefinitionNode $definition, D $typeDefinition = $documentAST->types[$typeName] ?? null; if (! $typeDefinition instanceof InputObjectTypeDefinitionNode) { - $printedType = \GraphQL\Language\Printer::doPrint($definition->type); + $printedType = Printer::doPrint($definition->type); throw new DefinitionException("The @nest directive must be used on input object types, got {$printedType} on {$location}."); } diff --git a/src/Support/Contracts/ArgResolver.php b/src/Support/Contracts/ArgResolver.php index 4c512263ce..07b2bc4b09 100644 --- a/src/Support/Contracts/ArgResolver.php +++ b/src/Support/Contracts/ArgResolver.php @@ -3,12 +3,21 @@ namespace Nuwave\Lighthouse\Support\Contracts; /** + * Resolve a slice of input arguments during mutation execution. + * + * Arg resolvers compose like field resolvers: each handles only its own + * nested input, and Lighthouse orchestrates traversal of the argument tree. + * + * @see \Nuwave\Lighthouse\Support\Contracts\SaveAwareArgResolver + * * @api */ interface ArgResolver { /** - * @param mixed $root the result of the parent resolver + * Handle the given slice of arguments and optionally mutate the root. + * + * @param mixed $root the result of the parent resolver, typically an Eloquent Model * @param mixed|\Nuwave\Lighthouse\Execution\Arguments\ArgumentSet|array<\Nuwave\Lighthouse\Execution\Arguments\ArgumentSet> $value the slice of arguments that belongs to this nested resolver * * @return mixed|void|null May return the modified $root