Skip to content

Support a sub-selection on IAsyncEnumerable and ValueTask list fields - #555

Merged
lukemurray merged 1 commit into
mainfrom
fix/async-stream-and-valuetask-shapes
Sep 8, 2026
Merged

lukemurray merged 1 commit into
mainfrom
fix/async-stream-and-valuetask-shapes

Conversation

@lukemurray

@lukemurray lukemurray commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to #554. Same family of bug, two shapes that never got there because they fail earlier.

What is broken

An async service field returning IAsyncEnumerable<T> or ValueTask<TCollection> cannot have a sub-selection at all:

{ people { tags { name } } }
Field 'people' - No generic method 'SelectWithNullCheck' on type
'EntityGraphQL.Extensions.EnumerableExtensions' is compatible with the supplied
type arguments and arguments.

MakeSelectWithDynamicType resolves that overload by the exact type of the expression it projects, and only IEnumerable<T> and Task<IEnumerable<T>> had one. SetUpField already normalizes Task<TCollection> → Task<IEnumerable<T>> for exactly this reason; neither of these shapes was covered.

This is why #554 deferred the IAsyncEnumerable<T> branch of GetResolvedFieldType - the shape could not reach it. It is also why that PR's ValueTask<T> guard had nothing to guard yet: a ValueTask list with a sub-selection never compiled.

Fix

  • IAsyncEnumerable<T> gets a SelectWithNullCheck overload of its own that projects lazily ([EnumeratorCancellation] async iterator). The field stays an IAsyncEnumerable through the whole pipeline, so the engine still buffers it - with the request's CancellationToken - instead of it being enumerated during compilation. Nothing about ConcurrencyLimitFieldExtension's deliberate IAsyncEnumerable exclusion changes.
  • ValueTask<T> is handed on as the Task<T> the rest of the pipeline already handles, next to the existing Task<TCollection> normalization.

The bug underneath

Making these shapes compile made #554's bug reachable one layer down:

Object of type 'Dynamic_Dynamic_tags_…' cannot be converted to type 'Dynamic_tags_…'

BufferAsyncEnumerable created a List<T> from the declared element type up front and added each resolved item to it - but resolving an item rebuilds it when its projection holds an async member, so it no longer fits. The IEnumerable path already had this right: resolve first, then choose the list type from what you actually have (CanMaterializeTypedCollection). The async path now does the same, and both go through one shared MaterializeResolvedItems.

GetResolvedFieldType's IAsyncEnumerable<T> branch takes the same guard #554 added to Task<T>/ValueTask<T>, now that it can be reached.

Tests

Four new tests, each failing on main with the errors above:

  • AsyncStreamShapeTests.AsyncEnumerableWithSubSelection
  • AsyncStreamShapeTests.AsyncEnumerableWithAsyncFieldOnTheItem — the deferred shape
  • ValueTaskShapeTests.ValueTaskOfListWithSubSelection
  • ValueTaskShapeTests.ValueTaskOfListWithAnAsyncFieldOnTheItem — Keep a rebuilt async list's own type when the item type changed #554's bug through ValueTask

Release build green on net8.0/9.0/10.0: 1206 passed / 1 skipped, plus 47 EF and 92 AspNet on each.

No version bump - 6.2.4 is unreleased, so these two entries join it in the CHANGELOG.

Not included

One more shape turned up in the sweep and is left alone as unrelated: an async mutation returning a type that is not part of the context graph (Task<Tag> / Task<List<Tag>> where Tag came from AddType) fails with Cannot implicitly convert type 'void' to 'object'. The sync equivalent works, and so does Task<List<Person>>. Separate issue.

🤖 Generated with Claude Code

An async service field returning IAsyncEnumerable<T> or ValueTask<TCollection>
could not have a sub-selection - `{ people { tags { name } } }` failed with
"No generic method 'SelectWithNullCheck'". MakeSelectWithDynamicType resolves
that overload by the exact type of the expression it projects, and only
IEnumerable<T> and Task<IEnumerable<T>> had one.

IAsyncEnumerable<T> gets an overload that projects lazily, so the engine still
buffers the stream itself with the request's CancellationToken rather than
enumerating it during compilation. ValueTask<T> is handed on as the Task<T>
the rest of the pipeline already handles, next to the existing
Task<TCollection> normalization in SetUpField.

That made 6.2.4's bug reachable one layer down: BufferAsyncEnumerable built a
List<T> from the declared element type before resolving anything, so an item
rebuilt to carry an awaited member no longer fit it. Items are now resolved
first and the list type chosen from them, which is what the IEnumerable path
already did - both now share that step. 6.2.4's guard extends to the
IAsyncEnumerable branch of GetResolvedFieldType for the same reason.

Co-Authored-By: Claude Opus 5 <[email protected]>
@lukemurray
lukemurray force-pushed the fix/async-stream-and-valuetask-shapes branch from f2f1f5f to c8f8bc6 Compare September 8, 2026 17:28
@lukemurray
lukemurray merged commit dd0d510 into main Sep 8, 2026
1 check passed
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.

1 participant