Skip to content

Commit 61bce25

Browse files
Fix NRE when ParentRequires targets a composite type (#9789)
1 parent d27871c commit 61bce25

6 files changed

Lines changed: 221 additions & 2 deletions

src/HotChocolate/Data/src/Data/Projections/Optimizers/QueryableRequirementsProjectionOptimizer.cs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ private static FieldNode CreateFieldNode(
135135
{
136136
if (requirements.Count == 0)
137137
{
138-
return null;
138+
return CreateWholeObjectSelectionSet(namedType);
139139
}
140140

141141
var mergedNode = new TypeNode(requirements[0].Type);
@@ -170,6 +170,29 @@ private static FieldNode CreateFieldNode(
170170
return selections.Count == 0 ? null : new SelectionSetNode(selections);
171171
}
172172

173+
private static SelectionSetNode? CreateWholeObjectSelectionSet(ITypeDefinition namedType)
174+
{
175+
// A composite requirement without an explicit sub-selection requires the whole object.
176+
// We emit a __typename-only selection set so the operation compiles, and the queryable
177+
// projection then binds the entire object reference instead of reconstructing its
178+
// members (see QueryableProjectionFieldHandler), matching SelectionExpressionBuilder.
179+
if (namedType.IsLeafType())
180+
{
181+
return null;
182+
}
183+
184+
return new SelectionSetNode(
185+
[
186+
new FieldNode(
187+
null,
188+
new NameNode(IntrospectionFieldNames.TypeName),
189+
null,
190+
[],
191+
[],
192+
null)
193+
]);
194+
}
195+
173196
private static bool TryGetField(
174197
ObjectType type,
175198
PropertyInfo property,

src/HotChocolate/Data/test/Data.EntityFramework.Tests/IntegrationTests.cs

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
using System.Data.Common;
2+
using System.Text.Json;
13
using HotChocolate.Execution;
24
using HotChocolate.Execution.Processing;
35
using HotChocolate.Types;
46
using Microsoft.EntityFrameworkCore;
7+
using Microsoft.EntityFrameworkCore.Diagnostics;
58
using Microsoft.Extensions.DependencyInjection;
6-
using System.Text.Json;
79

810
namespace HotChocolate.Data;
911

@@ -20,6 +22,91 @@ public IntegrationTests(AuthorFixture authorFixture)
2022
_singleOrDefaultAuthors = authorFixture.Context.SingleOrDefaultAuthors;
2123
}
2224

25+
[Fact]
26+
public async Task Projection_Should_ProjectRequiredNavigation_When_ParentRequiresObject()
27+
{
28+
// arrange
29+
var fileName = Guid.NewGuid().ToString("N") + ".db";
30+
var connectionString = "Data Source=" + fileName;
31+
var sql = new List<string>();
32+
33+
try
34+
{
35+
await using (var seed = new BookContext(
36+
new DbContextOptionsBuilder<BookContext>().UseSqlite(connectionString).Options))
37+
{
38+
await seed.Database.EnsureCreatedAsync();
39+
seed.Authors.Add(
40+
new Author { Id = 1, Name = "Foo", Books = { new Book { Id = 1, Title = "Foo1" } } });
41+
await seed.SaveChangesAsync();
42+
}
43+
44+
var result = await new ServiceCollection()
45+
.AddDbContext<BookContext>(
46+
b => b
47+
.UseSqlite(connectionString)
48+
.AddInterceptors(new SqlCapturingInterceptor(sql)))
49+
.AddGraphQL()
50+
.AddProjections()
51+
.AddQueryType(
52+
d => d
53+
.Name("Query")
54+
.Field("books")
55+
.Resolve(ctx => ctx.Service<BookContext>().Books)
56+
.UseProjection())
57+
.AddObjectType<Book>(
58+
d =>
59+
{
60+
d.Field(b => b.Title);
61+
d.Field("authorInfo")
62+
.Type<ObjectType<Author>>()
63+
.Resolve(ctx => ctx.Parent<Book>().Author)
64+
.ParentRequires<Book>(b => b.Author!);
65+
})
66+
.ModifyRequestOptions(o => o.IncludeExceptionDetails = true)
67+
.ExecuteRequestAsync("{ books { title authorInfo { name } } }");
68+
69+
// assert
70+
// The SQL must join and select the Authors columns, proving the required navigation
71+
// is projected from the database rather than relying on an in-memory object graph.
72+
Snapshot
73+
.Create(
74+
postFix: TestEnvironment.TargetFramework == "NET10_0"
75+
? TestEnvironment.TargetFramework
76+
: null)
77+
.Add(string.Join("\n", sql), "SQL")
78+
.Add(result, "Result")
79+
.MatchMarkdownSnapshot();
80+
}
81+
finally
82+
{
83+
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
84+
File.Delete(fileName);
85+
}
86+
}
87+
88+
private sealed class SqlCapturingInterceptor(List<string> queries) : DbCommandInterceptor
89+
{
90+
public override InterceptionResult<DbDataReader> ReaderExecuting(
91+
DbCommand command,
92+
CommandEventData eventData,
93+
InterceptionResult<DbDataReader> result)
94+
{
95+
queries.Add(command.CommandText);
96+
return base.ReaderExecuting(command, eventData, result);
97+
}
98+
99+
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
100+
DbCommand command,
101+
CommandEventData eventData,
102+
InterceptionResult<DbDataReader> result,
103+
CancellationToken cancellationToken = default)
104+
{
105+
queries.Add(command.CommandText);
106+
return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
107+
}
108+
}
109+
23110
[Fact]
24111
public async Task ExecuteAsync_Should_ReturnAllItems_When_ToListAsync()
25112
{
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Projection_Should_ProjectRequiredNavigation_When_ParentRequiresObject
2+
3+
## SQL
4+
5+
```text
6+
SELECT "b"."Title", "a"."Id", "a"."Name"
7+
FROM "Books" AS "b"
8+
INNER JOIN "Authors" AS "a" ON "b"."AuthorId" = "a"."Id"
9+
```
10+
11+
## Result
12+
13+
```json
14+
{
15+
"data": {
16+
"books": [
17+
{
18+
"title": "Foo1",
19+
"authorInfo": {
20+
"name": "Foo"
21+
}
22+
}
23+
]
24+
}
25+
}
26+
```
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Projection_Should_ProjectRequiredNavigation_When_ParentRequiresObject
2+
3+
## SQL
4+
5+
```text
6+
SELECT "b"."Title", "a"."Id", "a"."Name"
7+
FROM "Books" AS "b"
8+
INNER JOIN "Authors" AS "a" ON "b"."AuthorId" = "a"."Id"
9+
```
10+
11+
## Result
12+
13+
```json
14+
{
15+
"data": {
16+
"books": [
17+
{
18+
"title": "Foo1",
19+
"authorInfo": {
20+
"name": "Foo"
21+
}
22+
}
23+
]
24+
}
25+
}
26+
```

src/HotChocolate/Data/test/Data.Projections.Tests/IntegrationTests.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,42 @@ public async Task Projection_Should_NotBreakProjections_When_ExtensionsFieldRequ
3333
result.MatchSnapshot();
3434
}
3535

36+
[Fact]
37+
public async Task Projection_Should_NotThrow_When_ParentRequiresObjectField()
38+
{
39+
// arrange
40+
var executor = await new ServiceCollection()
41+
.AddGraphQL()
42+
.AddQueryType(q => q
43+
.Field("foo")
44+
.Resolve(_ => new[] { new RequiresFoo { Bar = new RequiresBar { Baz = "baz" } } }.AsQueryable())
45+
.UseProjection())
46+
.AddObjectType<RequiresFoo>(c =>
47+
{
48+
c.Field(f => f.Bar);
49+
c.Field("quux")
50+
.Resolve(ctx => ctx.Parent<RequiresFoo>().Bar)
51+
.ParentRequires<RequiresFoo>(f => f.Bar!);
52+
})
53+
.AddProjections()
54+
.BuildRequestExecutorAsync();
55+
56+
// act
57+
var result = await executor.ExecuteAsync(
58+
"""
59+
query {
60+
foo {
61+
quux {
62+
baz
63+
}
64+
}
65+
}
66+
""");
67+
68+
// assert
69+
result.MatchSnapshot();
70+
}
71+
3672
[Fact]
3773
public async Task Projection_Should_NotBreakProjections_When_ExtensionsListRequested()
3874
{
@@ -652,6 +688,16 @@ public class Foo
652688
public string FieldOfFoo => "fieldOfFoo";
653689
}
654690

691+
public class RequiresFoo
692+
{
693+
public RequiresBar? Bar { get; set; }
694+
}
695+
696+
public class RequiresBar
697+
{
698+
public string? Baz { get; set; }
699+
}
700+
655701
public class Baz
656702
{
657703
public string? Bar2 { get; set; }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"data": {
3+
"foo": [
4+
{
5+
"quux": {
6+
"baz": "baz"
7+
}
8+
}
9+
]
10+
}
11+
}

0 commit comments

Comments
 (0)