Skip to content

Allow nested arg resolvers to run before parent model is saved#2777

Merged
spawnia merged 49 commits into
masterfrom
pre-save-arg-resolver
Jul 2, 2026
Merged

Allow nested arg resolvers to run before parent model is saved#2777
spawnia merged 49 commits into
masterfrom
pre-save-arg-resolver

Conversation

@spawnia

@spawnia spawnia commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds SaveAwareArgResolver interface (@api) that lets ArgResolver directives declare whether they run before or after the parent model is saved during mutation execution.

Motivation

For BelongsTo relations, the FK lives on the parent model and must be set before $model->save().
The existing implicit detection in SaveModel handles this for known relation methods, but there was no way for custom directives to participate in the pre-save phase.

SaveAwareArgResolver is the extension point: any directive implementing it can control its execution timing relative to $model->save().

Design decisions

NestDirective becomes a pure marker — ResolveNested handles recursion directly, creating a fresh instance without SaveModel to prevent double-saves at each nesting level. Pre-save resolvers inside @nest are recursively lifted to the top level by ArgPartitioner::liftPreSaveResolversFromNest().

@nest now validates at schema build time that it is used on a non-list input object type (via ArgManipulator and InputFieldManipulator).

runBeforeSave(Model $model) is class-based, not instance-dependent — the model may not yet be hydrated when this is called. Implementations must base the decision on the model class and its relations.

When runBeforeSave() returns true, __invoke() may receive null as $value if the client sends null for a nullable input field.

Documentation

  • New SaveAwareArgResolver section in docs/master/custom-directives/input-value-directives.md with usage example
  • @nest directive reference notes the type constraint
  • ArgResolver interface PHPDoc improved with description and @see reference

🤖 Generated with Claude Code

…DEFINITION

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
@spawnia spawnia changed the title Add PreSaveArgResolver and support @belongsTo/@hasOne on INPUT_FIELD_DEFINITION Add PreSaveArgResolver, support @belongsTo/@hasOne on INPUT_FIELD_DEFINITION Jun 29, 2026
@spawnia spawnia requested a review from Copilot June 29, 2026 08:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new resolver marker interface to control nested mutation execution ordering, enabling relation directives to run nested resolvers either before or after the parent model is persisted. This is aimed at preventing NOT NULL FK violations for BelongsTo nested mutations by setting the FK prior to saving.

Changes:

  • Introduce PreSaveArgResolver to mark nested arg resolvers that must run before the parent model is saved.
  • Update ResolveNested to split nested resolvers into pre-save vs post-save execution phases.
  • Extend @belongsTo and @hasOne to support INPUT_FIELD_DEFINITION and act as nested arg resolvers.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
src/Support/Contracts/PreSaveArgResolver.php Adds a marker interface to indicate pre-save nested arg resolution.
src/Execution/Arguments/ResolveNested.php Partitions nested resolvers into pre-save/post-save and changes execution order accordingly.
src/Schema/Directives/BelongsToDirective.php Enables @belongsTo on input fields and implements pre-save nested resolver behavior.
src/Schema/Directives/HasOneDirective.php Enables @hasOne on input fields and implements nested resolver behavior (post-save).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Execution/Arguments/ResolveNested.php Outdated
Comment thread src/Schema/Directives/BelongsToDirective.php Outdated
Comment thread src/Support/Contracts/PreSaveArgResolver.php Outdated
Comment thread src/Execution/Arguments/ResolveNested.php Outdated
spawnia added 2 commits June 29, 2026 12:36
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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Comment thread src/Execution/Arguments/ResolveNested.php
Comment thread src/Execution/Arguments/ResolveNested.php Outdated
spawnia added 8 commits June 29, 2026 14:41
…ions

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.

#2777 (comment)
#2777 (comment)

🤖 Generated with Claude Code
…ng, test coverage

- 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
nestedArgResolvers → postSaveArgResolvers
preSaveResolvers → preSaveArgResolvers

🤖 Generated with Claude Code
🤖 Generated with Claude Code
…esolver

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
@spawnia spawnia changed the title Add PreSaveArgResolver, support @belongsTo/@hasOne on INPUT_FIELD_DEFINITION Add PreSaveArgResolver interface Jun 29, 2026
spawnia and others added 11 commits June 29, 2026 17:46
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
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
…detection

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
Both are documented user extension points for custom directives.

🤖 Generated with Claude Code
Documents the interface design for dynamic pre/post-save timing
in mutation ArgResolvers, replacing the static PreSaveArgResolver
marker interface.

🤖 Generated with Claude Code
🤖 Generated with Claude Code
Extracts relationName() and uses it to dynamically decide timing:
BelongsTo/MorphTo → pre-save, everything else → post-save.

🤖 Generated with Claude Code
nestedArgResolvers now excludes resolvers where runBeforeSave() is
true, passing them through to SaveModel which invokes them before
$model->save().

🤖 Generated with Claude Code
🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.

Comment thread src/Execution/Arguments/ArgPartitioner.php
Comment thread src/Execution/Arguments/ArgPartitioner.php Outdated
Comment thread src/Support/Contracts/SaveAwareArgResolver.php
Comment thread src/Execution/Arguments/ArgPartitioner.php Outdated
Comment thread src/Schema/Directives/ModelMutationDirective.php Outdated
Comment thread src/Schema/Directives/NestDirective.php Outdated
Comment thread src/Schema/Directives/NestDirective.php Outdated
Comment thread src/Support/Contracts/SaveAwareArgResolver.php
Comment thread tests/Integration/Schema/Directives/CreateDirectiveTest.php Outdated
Comment thread tests/Integration/Schema/Directives/CreateDirectiveTest.php Outdated
Comment thread tests/Integration/Schema/Directives/CreateDirectiveTest.php Outdated
Comment thread tests/Utils/Directives/GeocodeDirective.php
@spawnia spawnia changed the title Add PreSaveArgResolver interface Allow nested arg resolvers to run before parent model is saved Jul 1, 2026
spawnia and others added 9 commits July 1, 2026 08:45
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.

#2777

🤖 Generated with Claude Code
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
…e nesting

🤖 Generated with Claude Code
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
# Conflicts:
#	src/GlobalId/Base64GlobalId.php
#	src/Schema/Directives/BaseDirective.php
- Unit test for nestedArgResolversWithoutPreSave with non-Model root
- DB assertion in testUpsertBelongsToWithNullValue proving no User created

🤖 Generated with Claude Code
🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 2 comments.

Comment thread src/Execution/Arguments/ArgPartitioner.php Outdated
Comment thread src/Execution/Arguments/ResolveNested.php Outdated
spawnia added 4 commits July 1, 2026 12:11
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
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.

#2777 (comment)
#2777 (comment)

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.

spawnia added 3 commits July 2, 2026 09:37
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
🤖 Generated with Claude Code
@spawnia spawnia merged commit 961aecf into master Jul 2, 2026
93 checks passed
@spawnia spawnia deleted the pre-save-arg-resolver branch July 2, 2026 09:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants