diff --git a/.ai/AGENTS.md b/.ai/AGENTS.md index 596f0e557d..2e106d9e0f 100644 --- a/.ai/AGENTS.md +++ b/.ai/AGENTS.md @@ -23,9 +23,9 @@ 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 ``` ## Architecture 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)", diff --git a/CHANGELOG.md b/CHANGELOG.md index 65a8992ad8..c83d84ea03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ You can find and compare releases at the [GitHub release page](https://github.co ## Unreleased +### Added + +- Add `SaveAwareArgResolver` interface for directives that need control over pre/post-save timing in mutations https://github.com/nuwave/lighthouse/pull/2777 + ## v6.67.0 ### Changed 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 5fc82bb892..e001f2c741 100644 --- a/src/Execution/Arguments/ArgPartitioner.php +++ b/src/Execution/Arguments/ArgPartitioner.php @@ -10,7 +10,9 @@ 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; class ArgPartitioner @@ -18,21 +20,79 @@ class ArgPartitioner /** * Partition the arguments into nested 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 { - $model = $root instanceof Model - ? new \ReflectionClass($root) - : null; + static::prepareArgResolvers($argumentSet, $root); - foreach ($argumentSet->arguments as $name => $argument) { - 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 = static::prepareArgResolvers($argumentSet, $root); + + [$nested, $regular] = static::partition( + $argumentSet, + static function (string $name, Argument $argument) use ($root, $model): bool { + $resolver = $argument->resolver; + if ($resolver === null) { + return false; + } + + if ($model === null) { + return true; + } + + assert($root instanceof Model); + + if ($resolver instanceof SaveAwareArgResolver) { + return ! $resolver->runBeforeSave($root); + } + + return true; + }, + ); + + if ($model !== null) { + assert($root instanceof Model); + static::liftPreSaveResolversFromNest($nested, $regular, $root, $model); } + return [$nested, $regular]; + } + + /** + * Requires that attachNestedArgResolver() has run on the arguments first. + * + * @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 => isset($argument->resolver), + static fn (string $name, Argument $argument): bool => self::shouldRunBeforeSave($argument->resolver, $model), ); } @@ -81,54 +141,6 @@ public static function relationMethods( return [$nonNullRelations, $remaining]; } - /** - * 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. * @@ -195,4 +207,112 @@ 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/Execution/Arguments/ResolveNested.php b/src/Execution/Arguments/ResolveNested.php index ed1890dcaf..83a738181c 100644 --- a/src/Execution/Arguments/ResolveNested.php +++ b/src/Execution/Arguments/ResolveNested.php @@ -2,6 +2,7 @@ namespace Nuwave\Lighthouse\Execution\Arguments; +use Nuwave\Lighthouse\Schema\Directives\NestDirective; use Nuwave\Lighthouse\Support\Contracts\ArgResolver; class ResolveNested implements ArgResolver @@ -30,8 +31,23 @@ 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); + $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 ($value === null) { + continue; + } + + assert($value instanceof ArgumentSet, 'NestDirective validates that @nest is used on non-list input object types.'); + + $nestResolver = new self(null, $this->argPartitioner); + $nestResolver($root, $value); + continue; + } + + $resolver($root, $value); } return $root; 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. diff --git a/src/Schema/Directives/ModelMutationDirective.php b/src/Schema/Directives/ModelMutationDirective.php index 983632b726..8c0b126148 100644 --- a/src/Schema/Directives/ModelMutationDirective.php +++ b/src/Schema/Directives/ModelMutationDirective.php @@ -3,35 +3,53 @@ 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(), + // Includes MorphTo (a BelongsTo subclass) — explicit directives shadow implicit relation detection. + BelongsTo::class, + ); + } + /** * @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 { - $relationName = $this->directiveArgValue( - 'relation', - // Use the name of the argument if no explicit relation name is given - $this->nodeName(), - ); + if ($args === null) { + return null; + } - $relation = $model->{$relationName}(); + $relation = $model->{$this->relationName()}(); assert($relation instanceof Relation); $related = $relation->make(); // @phpstan-ignore method.notFound (Relation delegates to Builder) @@ -47,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..892e23f329 100644 --- a/src/Schema/Directives/NestDirective.php +++ b/src/Schema/Directives/NestDirective.php @@ -2,12 +2,25 @@ namespace Nuwave\Lighthouse\Schema\Directives; -use Nuwave\Lighthouse\Execution\Arguments\ArgumentSet; -use Nuwave\Lighthouse\Execution\Arguments\ResolveNested; +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 GraphQL\Language\Printer; +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\Utils; +use Nuwave\Lighthouse\Support\Contracts\InputFieldManipulator; -class NestDirective extends BaseDirective implements ArgResolver +/** + * Marker for nested input grouping — resolution is handled by ResolveNested. + */ +class NestDirective extends BaseDirective implements ArgResolver, ArgManipulator, InputFieldManipulator { public static function definition(): string { @@ -20,18 +33,56 @@ 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): void { - $resolveNested = new ResolveNested(); + 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}", + ); + } - return Utils::mapEach( - static fn (ArgumentSet $argumentSet): mixed => $resolveNested($root, $argumentSet), - $args, + 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 = 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 = 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 665fadc525..07b2bc4b09 100644 --- a/src/Support/Contracts/ArgResolver.php +++ b/src/Support/Contracts/ArgResolver.php @@ -2,10 +2,22 @@ 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 diff --git a/src/Support/Contracts/SaveAwareArgResolver.php b/src/Support/Contracts/SaveAwareArgResolver.php new file mode 100644 index 0000000000..c4f907da01 --- /dev/null +++ b/src/Support/Contracts/SaveAwareArgResolver.php @@ -0,0 +1,33 @@ +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. + * 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; +} diff --git a/tests/Integration/Schema/Directives/CreateDirectiveTest.php b/tests/Integration/Schema/Directives/CreateDirectiveTest.php index e50936dfc6..e484b269db 100644 --- a/tests/Integration/Schema/Directives/CreateDirectiveTest.php +++ b/tests/Integration/Schema/Directives/CreateDirectiveTest.php @@ -584,4 +584,296 @@ 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' + 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 */ <<<'GRAPHQL' + mutation { + createPost(input: { + title: "My post" + task: { + name: "New Task" + } + }) { + id + title + task { + id + name + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createPost' => [ + 'title' => 'My post', + 'task' => [ + 'id' => '1', + 'name' => 'New Task', + ], + ], + ], + ]); + } + + 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; + + $updatedName = 'Updated via directive'; + + $this->graphQL(/** @lang GraphQL */ <<<'GRAPHQL' + mutation ($id: ID!, $name: String!) { + createPost(input: { + title: "My post" + task: { + id: $id + name: $name + } + }) { + id + title + task { + id + name + } + } + } + GRAPHQL, [ + 'id' => $task->id, + 'name' => $updatedName, + ])->assertJson([ + 'data' => [ + 'createPost' => [ + 'title' => 'My post', + 'task' => [ + 'id' => (string) $task->id, + 'name' => $updatedName, + ], + ], + ], + ]); + + $task->refresh(); + $this->assertSame($updatedName, $task->name); + } + + public function testUpsertBelongsToWithNullValue(): 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: null + }) { + id + name + user { + id + } + } + } + GRAPHQL)->assertJson([ + 'data' => [ + 'createTask' => [ + 'name' => 'My task', + 'user' => null, + ], + ], + ]); + + $this->assertDatabaseCount('users', 0); + } + + public function testCustomDirectiveSetsModelAttributesBeforeSave(): 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, + ], + ], + ]); + } + + public function testCustomDirectiveSetsModelAttributesBeforeSaveInsideNest(): 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/Integration/Schema/Directives/NestDirectiveTest.php b/tests/Integration/Schema/Directives/NestDirectiveTest.php index 34e6313258..18020540c7 100644 --- a/tests/Integration/Schema/Directives/NestDirectiveTest.php +++ b/tests/Integration/Schema/Directives/NestDirectiveTest.php @@ -3,6 +3,8 @@ namespace Tests\Integration\Schema\Directives; use Tests\DBTestCase; +use Tests\Utils\Models\Task; +use Tests\Utils\Models\User; final class NestDirectiveTest extends DBTestCase { @@ -63,4 +65,767 @@ 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 testNestDoesNotSaveParentModelMultipleTimes(): void + { + $savingCount = 0; + User::saving(static 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 testSiblingNestBlocksWithSameChildNameLastWins(): 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 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' + 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'], + ], + ], + ], + ]); + } + + /** 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', + ], + ], + ], + ], + ]); + } } diff --git a/tests/Unit/Execution/Arguments/ArgPartitionerTest.php b/tests/Unit/Execution/Arguments/ArgPartitionerTest.php index 7c2f790f4a..581cf9a894 100644 --- a/tests/Unit/Execution/Arguments/ArgPartitionerTest.php +++ b/tests/Unit/Execution/Arguments/ArgPartitionerTest.php @@ -7,8 +7,10 @@ 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; use Tests\Utils\Models\User; use Tests\Utils\Models\WithoutRelationClassImport; @@ -110,4 +112,121 @@ 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 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(); + + $regular = new Argument(); + $argumentSet->arguments['regular'] = $regular; + + $saveAware = new Argument(); + $saveAware->directives->push(new SaveAwareNested()); + $argumentSet->arguments['saveAware'] = $saveAware; + + [$nestedArgs, $regularArgs] = ArgPartitioner::nestedArgResolversWithoutPreSave($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, + ); + } + + 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 new file mode 100644 index 0000000000..471a14788a --- /dev/null +++ b/tests/Unit/Execution/Arguments/Fixtures/SaveAwareNested.php @@ -0,0 +1,32 @@ +wasCalled = true; + $this->receivedRoot = $root; + } + + public function runBeforeSave(Model $model): bool + { + return true; + } + + public static function definition(): string + { + return /** @lang GraphQL */ <<<'GRAPHQL' + directive @saveAwareNested on INPUT_FIELD_DEFINITION + 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(); + } +} diff --git a/tests/Utils/Directives/GeocodeDirective.php b/tests/Utils/Directives/GeocodeDirective.php new file mode 100644 index 0000000000..6cae6c31eb --- /dev/null +++ b/tests/Utils/Directives/GeocodeDirective.php @@ -0,0 +1,38 @@ +toArray(); + $model->setAttribute('latitude', $address['lat']); + $model->setAttribute('longitude', $address['lng']); + } +} 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(); }); }