Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
537cd9b
Add PreSaveArgResolver and support @belongsTo/@hasOne on INPUT_FIELD_…
spawnia Jun 29, 2026
5df4e45
Fix pre-save resolvers for update/upsert, add MorphTo guard, add tests
spawnia Jun 29, 2026
89fd450
Fix dev commands: use docker compose run instead of exec
spawnia Jun 29, 2026
053d442
Handle PreSaveArgResolver inside SaveModel to prevent NOT NULL violat…
spawnia Jun 29, 2026
a727987
Address self-review findings: null guard, assert messages, deep nesti…
spawnia Jun 29, 2026
12ec01c
simplify AGENTS.md
spawnia Jun 29, 2026
b8c5bfe
Rename ArgPartitioner methods for pre/post-save symmetry
spawnia Jun 29, 2026
8f15973
Reformat array{} PHPDoc types to multi-line
spawnia Jun 29, 2026
714e0bf
format, fix stan
spawnia Jun 29, 2026
97c1d65
Fold directive-on-input-field tests into existing test classes
spawnia Jun 29, 2026
9c0e929
Remove @belongsTo/@hasOne on INPUT_FIELD_DEFINITION, keep PreSaveArgR…
spawnia Jun 29, 2026
a8b13aa
Replace @uppercase with @connectRelated test directive
spawnia Jun 29, 2026
12e220f
Apply php-cs-fixer changes
spawnia Jun 29, 2026
50d49a1
Pass null through to PreSaveArgResolver implementations
spawnia Jun 29, 2026
e09e9ab
Give PreSaveArgResolver directives precedence over implicit relation …
spawnia Jun 29, 2026
29d3f61
Add @api to ArgResolver and PreSaveArgResolver
spawnia Jun 29, 2026
2cd9165
Add SaveAwareArgResolver design spec
spawnia Jun 30, 2026
fff4bad
Remove PreSaveArgResolver in preparation for SaveAwareArgResolver
spawnia Jun 30, 2026
e421f7a
Add SaveAwareArgResolver interface
spawnia Jun 30, 2026
12d95df
Implement SaveAwareArgResolver in ModelMutationDirective
spawnia Jun 30, 2026
69961fb
Route SaveAwareArgResolver through pre-save in SaveModel
spawnia Jun 30, 2026
e5e32f7
Test @upsert on BelongsTo INPUT_FIELD_DEFINITION
spawnia Jun 30, 2026
4e30554
Test @geocode non-relation SaveAwareArgResolver
spawnia Jun 30, 2026
869e25d
Test SaveAwareArgResolver partitioning with Model and non-Model roots
spawnia Jun 30, 2026
94ebd01
Update CHANGELOG and apply php-cs-fixer
spawnia Jun 30, 2026
ee7b581
add settings
spawnia Jun 30, 2026
d469b61
Address self-review findings
spawnia Jun 30, 2026
1d67c85
Use double columns for geocode test precision
spawnia Jun 30, 2026
1a7b0cd
Guard GeocodeDirective against null args
spawnia Jun 30, 2026
9a19afb
Document MorphTo design decision in runBeforeSave
spawnia Jun 30, 2026
47373bf
Add null to GeocodeDirective PHPDoc for PHPStan
spawnia Jun 30, 2026
fa01d28
Fix SaveAwareArgResolver inside @nest being silently skipped
spawnia Jun 30, 2026
1a7fc75
Clarify runBeforeSave contract: class-based, not instance-dependent
spawnia Jun 30, 2026
0fefe35
Address review feedback
spawnia Jul 1, 2026
6ef90ed
Apply php-cs-fixer changes
spawnia Jul 1, 2026
bc1345a
Make NestDirective a pure marker, handle recursion in ResolveNested
spawnia Jul 1, 2026
296eac9
Test @nest interaction with belongsTo, mixed pre/post-save, and doubl…
spawnia Jul 1, 2026
adcba1d
Fix double-save when ResolveNested recurses into @nest
spawnia Jul 1, 2026
5c00fc8
Merge master (formatting commit shared)
spawnia Jul 1, 2026
a19cb91
Merge branch 'pre-save-arg-resolver' of github.com:nuwave/lighthouse …
spawnia Jul 1, 2026
50d97c5
Add missing test coverage for edge cases
spawnia Jul 1, 2026
46f05c0
Apply code style fix
spawnia Jul 1, 2026
2c59109
Extract shouldRunBeforeSave() and flatten control flow
spawnia Jul 1, 2026
eef6e10
protected
spawnia Jul 1, 2026
f27fa63
Validate @nest is used on non-list input object types
spawnia Jul 1, 2026
1f2b91b
extract variable
spawnia Jul 1, 2026
c0cfe5e
Add regression tests for @nest with pre-save arg resolver changes
spawnia Jul 2, 2026
5ee6805
Extract prepareArgResolvers() to share preamble
spawnia Jul 2, 2026
20a9787
Add docs for SaveAwareArgResolver and @nest type constraint
spawnia Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .ai/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .ai/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ You can find and compare releases at the [GitHub release page](https://github.co

## Unreleased

### Added

- 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
Expand Down
1 change: 1 addition & 0 deletions docs/master/api-reference/directives.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
47 changes: 47 additions & 0 deletions docs/master/custom-directives/input-value-directives.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
230 changes: 175 additions & 55 deletions src/Execution/Arguments/ArgPartitioner.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,89 @@
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
{
/**
* 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),
);
}

Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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));
}
}
}
}
20 changes: 18 additions & 2 deletions src/Execution/Arguments/ResolveNested.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading