Skip to content

Commit 4fa24bf

Browse files
Ko-Linclaude
andcommitted
Keep a rebuilt async list's own type when the item type changed
An async service field selected below an async service list field crashed with "Object of type 'List`1[System.Object]' cannot be converted to type 'IEnumerable`1[Dynamic_...]'" - e.g. floor { people { visitSummaries { ... } } } where people and visitSummaries both resolve via ResolveAsync. Resolving an item of the outer list rebuilds it, because the item projection holds an async member of its own, so the finished list no longer holds the item type the outer field was declared with and falls back to List<object>. GetResolvedFieldType unwrapped Task<T> to T unconditionally, so the rebuilt parent still declared that member IEnumerable<T> and setting the resolved list on it threw. Only keep T when the resolved value still is one, otherwise take the resolved value's own type - which is what the non-async path below it already does. ValueTask<T> had the same hole and takes the same guard. Bump to 6.2.4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 362e78f commit 4fa24bf

5 files changed

Lines changed: 48 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
# 6.2.4
2+
3+
## Fixes
4+
5+
- 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 - `floor { people { visitSummaries { ... } } }`, where both `people` and `visitSummaries` have a `ResolveAsync` resolver. Resolving an item of the outer list rebuilds it, because the item projection holds an async member of its own, so the finished list no longer holds the item type the outer field was declared with and falls back to `List<object>`. `GetResolvedFieldType` unwrapped `Task<T>` to `T` unconditionally, so the rebuilt parent still declared the member as the original `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 already did. `ValueTask<T>` had the same hole and takes the same guard.
6+
17
# 6.2.3
28

39
## Fixes

‎src/EntityGraphQL.AspNet/EntityGraphQL.AspNet.csproj‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<AssemblyName>EntityGraphQL.AspNet</AssemblyName>
55
<PackageId>EntityGraphQL.AspNet</PackageId>
66
<LangVersion>14</LangVersion>
7-
<PackageVersion>6.2.3</PackageVersion>
7+
<PackageVersion>6.2.4</PackageVersion>
88
<Description>Contains ASP.NET extensions and middleware for EntityGraphQL</Description>
99
<Authors>Luke Murray</Authors>
1010
<PackageProjectUrl>https://github.com/lukemurray/EntityGraphQL</PackageProjectUrl>

‎src/EntityGraphQL/Compiler/GqlNodes/ExecutableGraphQLStatement.cs‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1308,18 +1308,20 @@ private static void TryCompileResolutionPlan(Type originalType, Type newType, Li
13081308
/// </summary>
13091309
private static Type GetResolvedFieldType(Type originalType, object? resolvedValue)
13101310
{
1311-
// If the original type was Task<T>, extract T
1311+
// If the original type was Task<T>, extract T. Resolving the value can change its shape - a list of
1312+
// projections whose own members were rebuilt no longer fits the original T - so only keep T when the
1313+
// resolved value still is one.
13121314
if (typeof(Task).IsAssignableFrom(originalType) && originalType.IsGenericType)
13131315
{
13141316
var taskGenericType = originalType.GetGenericArguments().FirstOrDefault();
1315-
if (taskGenericType != null)
1317+
if (taskGenericType != null && (resolvedValue == null || taskGenericType.IsInstanceOfType(resolvedValue)))
13161318
return taskGenericType;
13171319
}
1318-
// If the original type was ValueTask<T>, extract T
1320+
// If the original type was ValueTask<T>, extract T - same caveat as Task<T> above
13191321
if (originalType.IsGenericType && originalType.GetGenericTypeDefinition() == typeof(ValueTask<>))
13201322
{
13211323
var vtGeneric = originalType.GetGenericArguments().FirstOrDefault();
1322-
if (vtGeneric != null)
1324+
if (vtGeneric != null && (resolvedValue == null || vtGeneric.IsInstanceOfType(resolvedValue)))
13231325
return vtGeneric;
13241326
}
13251327
// If the original type was IAsyncEnumerable<T>, convert to IEnumerable<T>

‎src/EntityGraphQL/EntityGraphQL.csproj‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<LangVersion>14.0</LangVersion>
55
<AssemblyName>EntityGraphQL</AssemblyName>
66
<PackageId>EntityGraphQL</PackageId>
7-
<PackageVersion>6.2.3</PackageVersion>
7+
<PackageVersion>6.2.4</PackageVersion>
88
<Description>A GraphQL library for .NET. Compiles queries into .NET Expressions (LinqProvider) for runtime execution against object graphs. E.g. against an ORM data model (EntityFramework or others) or just an in-memory object.</Description>
99
<Authors>Luke Murray</Authors>
1010
<PackageProjectUrl>https://github.com/lukemurray/EntityGraphQL</PackageProjectUrl>

‎src/tests/EntityGraphQL.Tests/QueryTests/AsyncShapesTests.cs‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,31 @@ public void ListOfComplexItems_KeepsTypedList()
9999
Assert.Equal(["t6"], ((IEnumerable<dynamic>)people[0].tagList).Select(t => (string)t.name).ToList());
100100
}
101101

102+
[Fact]
103+
public void AsyncListOfProjectedItems_WithAnAsyncFieldOnTheItem_StaysAssignableToTheField()
104+
{
105+
// the item projection carries an async field of its own, so resolving an item rebuilds it into a new
106+
// dynamic type. The parent field is declared Task<IEnumerable<oldItemType>>, and the rebuilt list no
107+
// longer fits that - the rebuilt parent has to take the resolved list's own type instead
108+
var schema = SchemaBuilder.FromObject<TestDataContext>();
109+
schema.AddType<Tag>("Tag", "A tag").AddField("name", t => t.Name, "Tag name");
110+
schema.Type<Person>().AddField("tags", "Tags via List<T>").ResolveAsync<TagListService>((p, s) => s.GetTagsAsync(p.Id));
111+
schema.Type<Tag>().AddField("label", "Label via a second service").ResolveAsync<TagLabelService>((t, s) => s.GetLabelAsync(t.Name));
112+
113+
var ctx = new TestDataContext { People = [new Person { Id = 3 }] };
114+
var services = new ServiceCollection().AddSingleton(new TagListService()).AddSingleton(new TagLabelService()).BuildServiceProvider();
115+
116+
var res = schema.ExecuteRequestWithContext(new QueryRequest { Query = "{ people { id tags { name label } } }" }, ctx, services, null);
117+
118+
Assert.Null(res.Errors);
119+
dynamic people = res.Data!["people"]!;
120+
var tags = ((IEnumerable<object>)people[0].tags).ToList();
121+
Assert.Single(tags);
122+
dynamic tag = tags[0];
123+
Assert.Equal("t3", tag.name);
124+
Assert.Equal("label:t3", tag.label);
125+
}
126+
102127
[Fact]
103128
public void IAsyncEnumerable_Field_Is_Buffered_To_List()
104129
{
@@ -148,6 +173,15 @@ public async Task<List<Tag>> GetTagsAsync(int id)
148173
}
149174
}
150175

176+
internal class TagLabelService
177+
{
178+
public async Task<string> GetLabelAsync(string name)
179+
{
180+
await System.Threading.Tasks.Task.Yield();
181+
return $"label:{name}";
182+
}
183+
}
184+
151185
internal class ArrayService
152186
{
153187
public async Task<string[]> GetTagsAsync(int id)

0 commit comments

Comments
 (0)