diff --git a/CHANGELOG.md b/CHANGELOG.md index b1cd3bbd7..c48e8b37c 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 + +- Allow injecting into every element of a list using an asterisk `*` wildcard path segment in `@inject` https://github.com/nuwave/lighthouse/pull/2280 + ## v6.69.0 ### Added diff --git a/docs/master/api-reference/directives.md b/docs/master/api-reference/directives.md index 325176b94..1fad092db 100644 --- a/docs/master/api-reference/directives.md +++ b/docs/master/api-reference/directives.md @@ -2060,6 +2060,8 @@ directive @inject( The target name of the argument into which the value is injected. You can use dot notation to set the value at arbitrary depth within the incoming argument. + Use an asterisk `*` as a path segment to inject the value into every + element of a list found at that position. """ name: String! ) repeatable on FIELD_DEFINITION @@ -2086,6 +2088,30 @@ type Mutation { } ``` +If you need to inject a value into every element of a list, such as a nested `create` mutation +for a `HasMany` relation, use an asterisk `*` as a wildcard path segment. + +```graphql +type Mutation { + updateUser(input: UpdateUserInput!): User + @update + @inject(context: "user.id", name: "input.tasks.create.*.user_id") +} + +input UpdateUserInput { + id: ID! + tasks: UpdateTasksHasManyInput +} + +input UpdateTasksHasManyInput { + create: [CreateTaskInput!] +} + +input CreateTaskInput { + title: String! +} +``` + ## @interface ```graphql diff --git a/src/Execution/Arguments/ArgumentSet.php b/src/Execution/Arguments/ArgumentSet.php index ae3d8142a..e1d82e5d5 100644 --- a/src/Execution/Arguments/ArgumentSet.php +++ b/src/Execution/Arguments/ArgumentSet.php @@ -3,6 +3,7 @@ namespace Nuwave\Lighthouse\Execution\Arguments; use Illuminate\Support\Collection; +use Nuwave\Lighthouse\Exceptions\DefinitionException; class ArgumentSet { @@ -68,33 +69,72 @@ public function exists(string $key): bool /** * Add a value at the dot-separated path. * - * Works just like @see \Illuminate\Support\Arr::add(). + * Works just like @see \Illuminate\Support\Arr::add(), but a path segment + * of `*` injects the value into every element of the list found at that + * position instead of a single key. */ public function addValue(string $path, mixed $value): self { - $argumentSet = $this; - $keys = explode('.', $path); - - while (count($keys) > 1) { - $key = array_shift($keys); - - // If the key doesn't exist at this depth, we will create an empty ArgumentSet - // to hold the next value, allowing us to create the ArgumentSet to hold a final - // value at the correct depth. Then we'll keep digging into the ArgumentSet. - if (! isset($argumentSet->arguments[$key])) { - $argument = new Argument(); - $argument->value = new self(); - $argumentSet->arguments[$key] = $argument; - } + $this->addValueAtKeys(explode('.', $path), $value); + + return $this; + } + + /** @param non-empty-array $keys */ + private function addValueAtKeys(array $keys, mixed $value): void + { + $key = array_shift($keys); - $argumentSet = $argumentSet->arguments[$key]->value; + if ($keys === []) { + $argument = new Argument(); + $argument->value = $value; + $this->arguments[$key] = $argument; + + return; } - $argument = new Argument(); - $argument->value = $value; - $argumentSet->arguments[array_shift($keys)] = $argument; + $throughWildcard = $keys[0] === '*'; - return $this; + // The branch leading up to a `*` might not have been given by the client + // at all, e.g. an optional nested input that was omitted. In that case, + // there is nothing to inject into, so we just do nothing. + if ($throughWildcard && ! isset($this->arguments[$key])) { + return; + } + + // If the key doesn't exist at this depth, we will create an empty ArgumentSet + // to hold the next value, allowing us to create the ArgumentSet to hold a final + // value at the correct depth. Then we'll keep digging into the ArgumentSet. + if (! isset($this->arguments[$key])) { + $argument = new Argument(); + $argument->value = new self(); + $this->arguments[$key] = $argument; + } + + $argument = $this->arguments[$key]; + + if (! $throughWildcard) { + $argument->value->addValueAtKeys($keys, $value); + + return; + } + + array_shift($keys); + if ($keys === []) { + throw new DefinitionException("Can not use `*` as the final path segment of the `name` argument of `@inject`, a field to inject into must follow, got: {$key}.*"); + } + + if (! is_array($argument->value)) { + throw new DefinitionException("Expected the value at `{$key}` to be a list because of the `*` wildcard used in the `name` argument of `@inject`, got: " . get_debug_type($argument->value) . '.'); + } + + foreach ($argument->value as $element) { + if (! $element instanceof self) { + throw new DefinitionException("Expected the elements of the list at `{$key}` to be inputs because of the `*` wildcard used in the `name` argument of `@inject`, got: " . get_debug_type($element) . '.'); + } + + $element->addValueAtKeys($keys, $value); + } } /** diff --git a/src/Schema/Directives/InjectDirective.php b/src/Schema/Directives/InjectDirective.php index e7860dcf6..2a6b013fe 100644 --- a/src/Schema/Directives/InjectDirective.php +++ b/src/Schema/Directives/InjectDirective.php @@ -28,6 +28,8 @@ public static function definition(): string The target name of the argument into which the value is injected. You can use dot notation to set the value at arbitrary depth within the incoming argument. + Use an asterisk `*` as a path segment to inject the value into every + element of a list found at that position. """ name: String! ) repeatable on FIELD_DEFINITION diff --git a/tests/Integration/Schema/Directives/InjectDirectiveTest.php b/tests/Integration/Schema/Directives/InjectDirectiveTest.php index 70e2ca50f..7429b05ca 100644 --- a/tests/Integration/Schema/Directives/InjectDirectiveTest.php +++ b/tests/Integration/Schema/Directives/InjectDirectiveTest.php @@ -2,6 +2,7 @@ namespace Tests\Integration\Schema\Directives; +use Nuwave\Lighthouse\Exceptions\DefinitionException; use Tests\DBTestCase; use Tests\Utils\Models\User; @@ -56,4 +57,180 @@ public function testCreateFromInputObjectWithDeepInjection(): void ], ]); } + + public function testInjectsIntoEveryElementOfANestedListWithWildcard(): void + { + $user = factory(User::class)->create(); + $this->be($user); + + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Task { + id: ID! + name: String! + user: User @belongsTo + } + + type User { + id: ID + tasks: [Task!]! @hasMany + } + + type Mutation { + updateUser(input: UpdateUserInput! @spread): User + @update + @inject(context: "user.id", name: "tasks.create.*.user_id") + } + + input UpdateUserInput { + id: ID! + tasks: UpdateTasksHasManyInput + } + + input UpdateTasksHasManyInput { + create: [CreateTaskInput!] + } + + input CreateTaskInput { + name: String + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($input: UpdateUserInput!) { + updateUser(input: $input) { + tasks { + name + user { + id + } + } + } + } + GRAPHQL, [ + 'input' => [ + 'id' => $user->getKey(), + 'tasks' => [ + 'create' => [ + ['name' => 'foo'], + ['name' => 'bar'], + ], + ], + ], + ])->assertJson([ + 'data' => [ + 'updateUser' => [ + 'tasks' => [ + [ + 'name' => 'foo', + 'user' => ['id' => (string) $user->getKey()], + ], + [ + 'name' => 'bar', + 'user' => ['id' => (string) $user->getKey()], + ], + ], + ], + ], + ]); + } + + public function testSkipsWildcardInjectionWhenTheTargetedListIsNotGiven(): void + { + $user = factory(User::class)->create(); + $this->be($user); + + $this->schema .= /** @lang GraphQL */ <<<'GRAPHQL' + type Task { + id: ID! + name: String! + } + + type User { + id: ID + tasks: [Task!]! @hasMany + } + + type Mutation { + updateUser(input: UpdateUserInput! @spread): User + @update + @inject(context: "user.id", name: "tasks.create.*.user_id") + } + + input UpdateUserInput { + id: ID! + tasks: UpdateTasksHasManyInput + } + + input UpdateTasksHasManyInput { + create: [CreateTaskInput!] + } + + input CreateTaskInput { + name: String + } + GRAPHQL; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($input: UpdateUserInput!) { + updateUser(input: $input) { + id + tasks { + id + } + } + } + GRAPHQL, [ + 'input' => [ + 'id' => $user->getKey(), + ], + ])->assertJson([ + 'data' => [ + 'updateUser' => [ + 'id' => (string) $user->getKey(), + 'tasks' => [], + ], + ], + ]); + } + + public function testThrowsWhenWildcardIsUsedOnAValueThatIsNotAList(): void + { + $user = factory(User::class)->create(); + $this->be($user); + + $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 + @inject(context: "user.id", name: "name.*.user_id") + } + + input CreateTaskInput { + name: String + } + GRAPHQL; + + $this->expectException(DefinitionException::class); + $this->expectExceptionMessage('Expected the value at `name` to be a list because of the `*` wildcard used in the `name` argument of `@inject`, got: string.'); + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation { + createTask(input: { + name: "foo" + }) { + id + } + } + GRAPHQL); + } } diff --git a/tests/Unit/Execution/Arguments/ArgumentSetTest.php b/tests/Unit/Execution/Arguments/ArgumentSetTest.php index 3e7ffde50..36adecea5 100644 --- a/tests/Unit/Execution/Arguments/ArgumentSetTest.php +++ b/tests/Unit/Execution/Arguments/ArgumentSetTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Execution\Arguments; +use Nuwave\Lighthouse\Exceptions\DefinitionException; use Nuwave\Lighthouse\Execution\Arguments\Argument; use Nuwave\Lighthouse\Execution\Arguments\ArgumentSet; use Tests\TestCase; @@ -153,4 +154,118 @@ public function testAddValueDeep(): void $this->assertEmpty($bar->directives); $this->assertNull($bar->resolver); } + + public function testAddValueWithWildcardInjectsIntoEachListElement(): void + { + $set = new ArgumentSet(); + $set->arguments['create'] = $this->listOfArgumentSets( + $this->argumentSetWithSibling('a'), + $this->argumentSetWithSibling('b'), + ); + + $set->addValue('create.*.user_id', 123); + + $create = $set->arguments['create']->value; + $this->assertSame(123, $create[0]->arguments['user_id']->value); + $this->assertSame('a', $create[0]->arguments['sibling']->value); + $this->assertSame(123, $create[1]->arguments['user_id']->value); + $this->assertSame('b', $create[1]->arguments['sibling']->value); + } + + public function testAddValueWithWildcardSkipsWhenListArgumentIsMissing(): void + { + $set = new ArgumentSet(); + $set->addValue('create.*.user_id', 123); + + $this->assertFalse($set->exists('create')); + } + + public function testAddValueWithWildcardSkipsWhenNestedContainerIsMissing(): void + { + $set = new ArgumentSet(); + $set->addValue('tasks.create.*.user_id', 123); + + // The intermediary `tasks` input is created just like plain dot notation would, + // but nothing is injected because `create` was never provided by the client. + $this->assertTrue($set->exists('tasks')); + + $tasks = $set->arguments['tasks']->value; + $this->assertInstanceOf(ArgumentSet::class, $tasks); + $this->assertFalse($tasks->exists('create')); + } + + public function testAddValueWithWildcardSkipsWhenListIsEmpty(): void + { + $set = new ArgumentSet(); + $set->arguments['create'] = $this->listOfArgumentSets(); + + $set->addValue('create.*.user_id', 123); + + $this->assertSame([], $set->arguments['create']->value); + } + + public function testAddValueWithWildcardDoesNotOverwriteUnrelatedFalsyValues(): void + { + $element = $this->argumentSetWithSibling(false); + + $set = new ArgumentSet(); + $set->arguments['create'] = $this->listOfArgumentSets($element); + + $set->addValue('create.*.user_id', 123); + + $create = $set->arguments['create']->value; + $this->assertSame(123, $create[0]->arguments['user_id']->value); + $this->assertFalse($create[0]->arguments['sibling']->value); + } + + public function testAddValueWithWildcardThrowsWhenValueIsNotAList(): void + { + $set = new ArgumentSet(); + $create = new Argument(); + $create->value = 'not-a-list'; + $set->arguments['create'] = $create; + + $this->expectException(DefinitionException::class); + $set->addValue('create.*.user_id', 123); + } + + public function testAddValueWithWildcardThrowsWhenListElementIsNotAnArgumentSet(): void + { + $set = new ArgumentSet(); + $create = new Argument(); + $create->value = ['not-an-argument-set']; + $set->arguments['create'] = $create; + + $this->expectException(DefinitionException::class); + $set->addValue('create.*.user_id', 123); + } + + public function testAddValueWithWildcardThrowsWhenUsedAsFinalPathSegment(): void + { + $set = new ArgumentSet(); + $set->arguments['create'] = $this->listOfArgumentSets($this->argumentSetWithSibling('a')); + + $this->expectException(DefinitionException::class); + $set->addValue('create.*', 123); + } + + /** Build a list element that already carries an unrelated argument, to prove injection leaves it untouched. */ + private function argumentSetWithSibling(mixed $value): ArgumentSet + { + $sibling = new Argument(); + $sibling->value = $value; + + $argumentSet = new ArgumentSet(); + $argumentSet->arguments['sibling'] = $sibling; + + return $argumentSet; + } + + private function listOfArgumentSets(ArgumentSet ...$argumentSets): Argument + { + $argument = new Argument(); + $argument->value = $argumentSets; + + return $argument; + } }