Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Fixes

- Fixed `No generic method 'SelectWithNullCheck'` for a sub-selection on a field returning `IAsyncEnumerable<T>` or `ValueTask<TCollection>` - `{ people { tags { name } } }` where `tags` has a `ResolveAsync` returning either. `MakeSelectWithDynamicType` emits a `SelectWithNullCheck` whose overload is resolved by the exact type of the expression it projects, and only `IEnumerable<T>` and `Task<IEnumerable<T>>` had one. `IAsyncEnumerable<T>` now has an overload of its own that projects lazily, so the stream is still buffered by the engine with the request's `CancellationToken` rather than being enumerated during compilation, and `ValueTask<T>` is handed on as the `Task<T>` the rest of the pipeline already handles, next to the existing `Task<TCollection>` normalization.
- Fixed `Object of type 'Dynamic_...' cannot be converted to type 'Dynamic_...'` when buffering an `IAsyncEnumerable<T>` whose items are rebuilt - the list rebuild mismatch below, one layer down. `BufferAsyncEnumerable` created a `List<T>` from the declared element type before resolving anything and added each resolved item to it, so an item rebuilt to carry an awaited member no longer fit. Items are now resolved first and the list type chosen from them, which is what the `IEnumerable` path already did - both paths now share that step. The same guard added above for `Task<T>` and `ValueTask<T>` in `GetResolvedFieldType` applies to `IAsyncEnumerable<T>`, which is reachable now that these shapes compile.
- Fixed `Object of type 'System.Collections.Generic.List`1[System.Object]' cannot be converted to type 'System.Collections.Generic.IEnumerable`1[Dynamic_...]'` for a query selecting an async service field below an async service *list* field - `{ people { tags { name label } } }` where `tags` has a `ResolveAsync` returning a list and `label` on the item type has one of its own. Resolving an item of the outer list rebuilds it, because the item projection holds an async member, so the finished list no longer holds the item type the outer field was declared with and correctly falls back to `List<object>`. `GetResolvedFieldType` unwrapped `Task<T>` to `T` unconditionally, so the rebuilt parent still declared the member `IEnumerable<T>` and setting the resolved list on it threw. `T` is now only kept when the resolved value still is one, otherwise the resolved value's own type is used - which is what the non-async path in the same method already did. `ValueTask<T>` had the same hole and takes the same guard.

# 6.2.3
Expand Down
48 changes: 25 additions & 23 deletions src/EntityGraphQL/Compiler/GqlNodes/ExecutableGraphQLStatement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -938,16 +938,7 @@ public void AddDirectives(IEnumerable<GraphQLDirective> graphQLDirectives)
? originalType
: originalType.GetInterfaces().FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>));
if (enumerableInterface != null)
{
var elementType = enumerableInterface.GetGenericArguments()[0];
if (CanMaterializeTypedCollection(elementType, resolvedItems))
{
var typedList = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(elementType))!;
foreach (var item in resolvedItems)
typedList.Add(item);
return typedList;
}
}
return MaterializeResolvedItems(enumerableInterface.GetGenericArguments()[0], resolvedItems);

return resolvedItems;
}
Expand All @@ -961,6 +952,21 @@ public void AddDirectives(IEnumerable<GraphQLDirective> graphQLDirectives)
return obj;
}

/// <summary>
/// A List&lt;elementType&gt; if every resolved item still fits it - so the collection stays assignable back to
/// a typed field - otherwise the untyped list. Resolving an item rebuilds it when its projection holds an
/// async member, and the rebuilt type is not the one the field was declared with.
/// </summary>
private static object MaterializeResolvedItems(Type elementType, List<object?> resolvedItems)
{
if (!CanMaterializeTypedCollection(elementType, resolvedItems))
return resolvedItems;
var typedList = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(elementType))!;
foreach (var item in resolvedItems)
typedList.Add(item);
return typedList;
}

private static bool CanMaterializeTypedCollection(Type elementType, IEnumerable<object?> items)
{
return items.All(item => IsCompatibleCollectionItem(elementType, item));
Expand Down Expand Up @@ -1324,11 +1330,12 @@ private static Type GetResolvedFieldType(Type originalType, object? resolvedValu
if (vtGeneric != null && (resolvedValue == null || vtGeneric.IsInstanceOfType(resolvedValue)))
return vtGeneric;
}
// If the original type was IAsyncEnumerable<T>, convert to IEnumerable<T>
// If the original type was IAsyncEnumerable<T>, convert to IEnumerable<T> - same caveat as Task<T> above
if (originalType.IsGenericType && originalType.GetGenericTypeDefinition() == typeof(IAsyncEnumerable<>))
{
var t = originalType.GetGenericArguments()[0];
return typeof(IEnumerable<>).MakeGenericType(t);
var enumerableOfT = typeof(IEnumerable<>).MakeGenericType(originalType.GetGenericArguments()[0]);
if (resolvedValue == null || enumerableOfT.IsInstanceOfType(resolvedValue))
return enumerableOfT;
}

// Boxing a Nullable<T> that has a value produces a boxed T, so GetType() can never report the type as
Expand Down Expand Up @@ -1439,10 +1446,9 @@ internal static async Task<object> BufferAsyncEnumerable(object asyncEnumerableO
elementType = asyncEnumerableInterface.GetGenericArguments()[0];
}

// Create the properly typed list upfront
var listType = typeof(List<>).MakeGenericType(elementType);
var typedList = Activator.CreateInstance(listType)!;
var addMethod = listType.GetMethod("Add")!;
// Resolving an item can change its type (a projection holding an async member is rebuilt), so collect
// untyped and pick the list type at the end from what we actually have - as the IEnumerable path does
var resolvedItems = new List<object?>();

var getEnumeratorMethod = asyncEnumerableType.GetMethod("GetAsyncEnumerator", BindingFlags.Public | BindingFlags.Instance);

Expand Down Expand Up @@ -1503,11 +1509,7 @@ internal static async Task<object> BufferAsyncEnumerable(object asyncEnumerableO
break;

var current = currentProperty.GetValue(enumerator);
if (current != null)
{
var resolvedCurrent = await ResolveAsyncResultsRecursive(current, cancellationToken);
addMethod.Invoke(typedList, [resolvedCurrent]);
}
resolvedItems.Add(current != null ? await ResolveAsyncResultsRecursive(current, cancellationToken) : null);
}
else
{
Expand All @@ -1528,7 +1530,7 @@ internal static async Task<object> BufferAsyncEnumerable(object asyncEnumerableO
}
}

return typedList;
return MaterializeResolvedItems(elementType, resolvedItems);
}

public IEnumerable<string> BuildPath()
Expand Down
21 changes: 21 additions & 0 deletions src/EntityGraphQL/Extensions/EnumerableExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,27 @@ internal static async Task<IEnumerable<TSource>> ToEnumerableTask<TCollection, T
return await task;
}

/// <summary>
/// Projects an <c>IAsyncEnumerable{T}</c> without enumerating it - the engine buffers the stream later,
/// with the request's CancellationToken, the same as it does for an unprojected one.
/// </summary>
public static IAsyncEnumerable<TResult>? SelectWithNullCheck<TSource, TResult>(this IAsyncEnumerable<TSource>? source, Func<TSource, TResult> selector)
{
if (source == null)
return null;
return SelectAsyncIterator(source, selector);
}

private static async IAsyncEnumerable<TResult> SelectAsyncIterator<TSource, TResult>(
IAsyncEnumerable<TSource> source,
Func<TSource, TResult> selector,
[System.Runtime.CompilerServices.EnumeratorCancellation] System.Threading.CancellationToken cancellationToken = default
)
{
await foreach (var item in source.WithCancellation(cancellationToken))
yield return selector(item);
}

public static IEnumerable<TResult>? SelectWithNullCheck<TSource, TResult>(this IEnumerable<TSource>? source, Func<TSource, TResult> selector, bool returnEmptyList)
{
if (source == null)
Expand Down
5 changes: 5 additions & 0 deletions src/EntityGraphQL/Schema/Field.cs
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,11 @@ protected void SetUpField(LambdaExpression fieldExpression, bool withServices, b
// We do it here — once at registration — rather than at every compilation site.
if (ResolveExpression != null)
{
// ValueTask<T> has no Select overloads of its own - hand it on as the Task<T> everything below
// (and the whole compilation pipeline) already handles
if (ResolveExpression.Type.IsGenericType && ResolveExpression.Type.GetGenericTypeDefinition() == typeof(ValueTask<>))
ResolveExpression = Expression.Call(ResolveExpression, ResolveExpression.Type.GetMethod(nameof(ValueTask<object>.AsTask))!);

var exprType = ResolveExpression.Type;
if (exprType.IsGenericType && exprType.GetGenericTypeDefinition() == typeof(Task<>))
{
Expand Down
55 changes: 55 additions & 0 deletions src/tests/EntityGraphQL.Tests/QueryTests/AsyncStreamShapeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using System.Collections.Generic;
using System.Linq;
using EntityGraphQL.Schema;
using Microsoft.Extensions.DependencyInjection;
using Xunit;

namespace EntityGraphQL.Tests;

public class AsyncStreamShapeTests
{
[Fact]
public void AsyncEnumerableWithSubSelection()
{
var schema = SchemaBuilder.FromObject<TestDataContext>();
schema.AddType<Tag>("Tag", "A tag").AddAllFields();
schema.Type<Person>().AddField("tags", "").ResolveAsync<TagStreamService, Tag>((p, s) => s.GetTagsAsync(p.Id));

var ctx = new TestDataContext { People = [new Person { Id = 3 }] };
var services = new ServiceCollection().AddSingleton(new TagStreamService()).BuildServiceProvider();

var res = schema.ExecuteRequestWithContext(new QueryRequest { Query = "{ people { id tags { name } } }" }, ctx, services, null);
Assert.Null(res.Errors);
dynamic people = res.Data!["people"]!;
Assert.Equal("t3", ((dynamic)((IEnumerable<object>)people[0].tags).First()).name);
}

// the shape the PR deferred: async field on the streamed item type
[Fact]
public void AsyncEnumerableWithAsyncFieldOnTheItem()
{
var schema = SchemaBuilder.FromObject<TestDataContext>();
schema.AddType<Tag>("Tag", "A tag").AddAllFields();
schema.Type<Person>().AddField("tags", "").ResolveAsync<TagStreamService, Tag>((p, s) => s.GetTagsAsync(p.Id));
schema.Type<Tag>().AddField("label", "").ResolveAsync<TagLabelService>((t, s) => s.GetLabelAsync(t.Name));

var ctx = new TestDataContext { People = [new Person { Id = 3 }] };
var services = new ServiceCollection().AddSingleton(new TagStreamService()).AddSingleton(new TagLabelService()).BuildServiceProvider();

var res = schema.ExecuteRequestWithContext(new QueryRequest { Query = "{ people { id tags { name label } } }" }, ctx, services, null);
Assert.True(res.Errors == null, res.Errors == null ? "" : string.Join(" | ", res.Errors.Select(e => e.Message)));
dynamic people = res.Data!["people"]!;
var tags = ((IEnumerable<object>)people[0].tags).ToList();
Assert.Single(tags);
Assert.Equal("label:t3", ((dynamic)tags[0]).label);
}
}

internal class TagStreamService
{
public async IAsyncEnumerable<Tag> GetTagsAsync(int id)
{
await System.Threading.Tasks.Task.Yield();
yield return new Tag { Name = $"t{id}" };
}
}
53 changes: 53 additions & 0 deletions src/tests/EntityGraphQL.Tests/QueryTests/ValueTaskShapeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using System.Collections.Generic;
using System.Linq;
using EntityGraphQL.Schema;
using Microsoft.Extensions.DependencyInjection;
using Xunit;

namespace EntityGraphQL.Tests;

public class ValueTaskShapeTests
{
[Fact]
public void ValueTaskOfListWithSubSelection()
{
var schema = SchemaBuilder.FromObject<TestDataContext>();
schema.AddType<Tag>("Tag", "A tag").AddAllFields();
schema.Type<Person>().AddField("tags", "").ResolveAsync<VtTagService, List<Tag>>((p, s) => s.GetTagsAsync(p.Id));

var ctx = new TestDataContext { People = [new Person { Id = 3 }] };
var services = new ServiceCollection().AddSingleton(new VtTagService()).BuildServiceProvider();

var res = schema.ExecuteRequestWithContext(new QueryRequest { Query = "{ people { tags { name } } }" }, ctx, services, null);
Assert.True(res.Errors == null, res.Errors == null ? "" : string.Join(" | ", res.Errors.Select(e => e.Message)));
dynamic people = res.Data!["people"]!;
Assert.Equal("t3", ((dynamic)((IEnumerable<object>)people[0].tags).First()).name);
}

// the bug this PR fixes, through ValueTask instead of Task
[Fact]
public void ValueTaskOfListWithAnAsyncFieldOnTheItem()
{
var schema = SchemaBuilder.FromObject<TestDataContext>();
schema.AddType<Tag>("Tag", "A tag").AddAllFields();
schema.Type<Person>().AddField("tags", "").ResolveAsync<VtTagService, List<Tag>>((p, s) => s.GetTagsAsync(p.Id));
schema.Type<Tag>().AddField("label", "").ResolveAsync<TagLabelService>((t, s) => s.GetLabelAsync(t.Name));

var ctx = new TestDataContext { People = [new Person { Id = 3 }] };
var services = new ServiceCollection().AddSingleton(new VtTagService()).AddSingleton(new TagLabelService()).BuildServiceProvider();

var res = schema.ExecuteRequestWithContext(new QueryRequest { Query = "{ people { tags { name label } } }" }, ctx, services, null);
Assert.True(res.Errors == null, res.Errors == null ? "" : string.Join(" | ", res.Errors.Select(e => e.Message)));
dynamic people = res.Data!["people"]!;
Assert.Equal("label:t3", ((dynamic)((IEnumerable<object>)people[0].tags).First()).label);
}
}

internal class VtTagService
{
public async System.Threading.Tasks.ValueTask<List<Tag>> GetTagsAsync(int id)
{
await System.Threading.Tasks.Task.Yield();
return [new Tag { Name = $"t{id}" }];
}
}