Skip to content

Commit 55c488b

Browse files
authored
Add field projections
1 parent 914a80a commit 55c488b

109 files changed

Lines changed: 4181 additions & 86 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

claude.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
44

5+
## Workflow Guidelines
6+
7+
**IMPORTANT - Git Commits:**
8+
- NEVER automatically commit changes
9+
- NEVER prompt or ask to commit changes
10+
- NEVER suggest creating commits
11+
- The user will handle all git commits manually
12+
513
## Project Overview
614

715
GraphQL.EntityFramework is a .NET library that adds EntityFramework Core IQueryable support to GraphQL.NET. It enables automatic query generation, filtering, pagination, and ordering for GraphQL queries backed by EF Core.
@@ -67,6 +75,10 @@ The README.md and docs/*.md files are auto-generated from source files using [Ma
6775
- Builds LINQ expressions from GraphQL where clause arguments
6876
- Supports complex filtering including grouping, negation, and nested properties
6977

78+
**ProjectionAnalyzer** (`src/GraphQL.EntityFramework/GraphApi/ProjectionAnalyzer.cs`)
79+
- Analyzes projection expressions to extract required property names
80+
- Used by navigation fields, filters, and FieldBuilder extensions to determine which properties need to be loaded
81+
7082
**Filters** (`src/GraphQL.EntityFramework/Filters/Filters.cs`)
7183
- Post-query filtering mechanism for authorization or business rules
7284
- Executed after EF query to determine if nodes should be included in results
@@ -136,6 +148,60 @@ Arguments are processed in order: ids → where → orderBy → skip → take
136148

137149
The library supports EF projections where you can use `Select()` to project to DTOs or anonymous types before applying GraphQL field resolution.
138150

151+
### FieldBuilder Extensions (Projection-Based Resolve)
152+
153+
The library provides projection-based extension methods on `FieldBuilder` to safely access navigation properties in custom resolvers:
154+
155+
**Extension Methods** (`src/GraphQL.EntityFramework/GraphApi/FieldBuilderExtensions.cs`)
156+
- `Resolve<TDbContext, TSource, TReturn, TProjection>()` - Synchronous resolver with projection
157+
- `ResolveAsync<TDbContext, TSource, TReturn, TProjection>()` - Async resolver with projection
158+
- `ResolveList<TDbContext, TSource, TReturn, TProjection>()` - List resolver with projection
159+
- `ResolveListAsync<TDbContext, TSource, TReturn, TProjection>()` - Async list resolver with projection
160+
161+
**Why Use These Methods:**
162+
When using `Field().Resolve()` or `Field().ResolveAsync()` directly, navigation properties on `context.Source` may be null if the projection system didn't include them. The projection-based extension methods ensure required data is loaded by:
163+
1. Storing projection metadata in field metadata
164+
2. Compiling the projection expression for runtime execution
165+
3. Applying the projection to `context.Source` before calling your resolver
166+
4. Providing the projected data via `ResolveProjectionContext<TDbContext, TProjection>`
167+
168+
**Example:**
169+
```csharp
170+
public class ChildGraphType : EfObjectGraphType<IntegrationDbContext, ChildEntity>
171+
{
172+
public ChildGraphType(IEfGraphQLService<IntegrationDbContext> graphQlService) : base(graphQlService) =>
173+
Field<int>("ParentId")
174+
.Resolve<IntegrationDbContext, ChildEntity, int, ParentEntity>(
175+
projection: x => x.Parent!,
176+
resolve: ctx => ctx.Projection.Id);
177+
}
178+
```
179+
180+
## Roslyn Analyzer
181+
182+
The project includes a Roslyn analyzer (`GraphQL.EntityFramework.Analyzers`) that detects problematic usage patterns at compile time:
183+
184+
**GQLEF002**: Warns when using `Field().Resolve()` or `Field().ResolveAsync()` to access navigation properties without projection
185+
- **Category:** Usage
186+
- **Severity:** Warning
187+
- **Solution:** Use projection-based extension methods instead
188+
189+
**Safe Patterns (No Warning):**
190+
- Accessing primary key properties (e.g., `context.Source.Id`, `context.Source.CompanyId` when in `Company` class)
191+
- Accessing foreign key properties (e.g., `context.Source.ParentId`, `context.Source.UserId`)
192+
- Using projection-based extension methods
193+
194+
**Unsafe Patterns (Warning):**
195+
- Accessing scalar properties (e.g., `context.Source.Name`, `context.Source.Age`) - these might not be loaded
196+
- Accessing navigation properties (e.g., `context.Source.Parent`)
197+
- Accessing properties on navigation properties (e.g., `context.Source.Parent.Id`)
198+
- Accessing collection navigation properties (e.g., `context.Source.Children.Count()`)
199+
200+
**Why Only PK/FK Are Safe:**
201+
The EF projection system always loads primary keys and foreign keys, but other properties (including regular scalars like `Name` or `Age`) are only loaded if explicitly requested in the GraphQL query. Accessing them in a resolver without projection can cause null reference exceptions.
202+
203+
The analyzer automatically runs during build and in IDEs (Visual Studio, Rider, VS Code).
204+
139205
## Testing
140206

141207
Tests use:
@@ -157,3 +223,4 @@ The test project (`src/Tests/`) includes:
157223
- Treats warnings as errors
158224
- Uses .editorconfig for code style enforcement
159225
- Uses Fody/ConfigureAwait.Fody for ConfigureAwait(false) injection
226+
- Always use `_ => _` for single parameter delegates (not `x => x` or other named parameters)

docs/configuration.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -721,9 +721,9 @@ public class BaseGraphType :
721721
{
722722
public BaseGraphType(IEfGraphQLService<IntegrationDbContext> graphQlService) :
723723
base(graphQlService) =>
724-
AddNavigationConnectionField<DerivedChildEntity>(
724+
AddNavigationConnectionField(
725725
name: "childrenFromInterface",
726-
includeNames: ["ChildrenFromBase"]);
726+
projection: _ => _.ChildrenFromBase);
727727
}
728728
```
729729
<sup><a href='/src/Tests/IntegrationTests/Graphs/Inheritance/BaseGraphType.cs#L1-L9' title='Snippet source file'>snippet source</a> | <a href='#snippet-BaseGraphType.cs' title='Start of snippet'>anchor</a></sup>
@@ -740,14 +740,15 @@ public class DerivedGraphType :
740740
{
741741
AddNavigationConnectionField(
742742
name: "childrenFromInterface",
743-
_ => _.Source.ChildrenFromBase);
743+
projection: _ => _.ChildrenFromBase,
744+
resolve: _ => _.Projection);
744745
AutoMap();
745746
Interface<BaseGraphType>();
746747
IsTypeOf = obj => obj is DerivedEntity;
747748
}
748749
}
749750
```
750-
<sup><a href='/src/Tests/IntegrationTests/Graphs/Inheritance/DerivedGraphType.cs#L1-L14' title='Snippet source file'>snippet source</a> | <a href='#snippet-DerivedGraphType.cs' title='Start of snippet'>anchor</a></sup>
751+
<sup><a href='/src/Tests/IntegrationTests/Graphs/Inheritance/DerivedGraphType.cs#L1-L15' title='Snippet source file'>snippet source</a> | <a href='#snippet-DerivedGraphType.cs' title='Start of snippet'>anchor</a></sup>
751752
<!-- endSnippet -->
752753

753754

docs/defining-graphs.md

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,29 @@ context.Heros
4646
.Include("Friends.Address");
4747
```
4848

49-
The string for the include is taken from the field name when using `AddNavigationField` or `AddNavigationConnectionField` with the first character upper cased. This value can be overridden using the optional parameter `includeNames` . Note that `includeNames` is an `IEnumerable<string>` so that multiple navigation properties can optionally be included for a single node.
49+
The string for the include is taken from the field name when using `AddNavigationField` or `AddNavigationConnectionField` with the first character upper cased. This can be customized using a projection expression:
50+
51+
```cs
52+
// Using projection to specify which navigation properties to include
53+
AddNavigationConnectionField(
54+
name: "employeesConnection",
55+
projection: _ => _.Employees,
56+
resolve: ctx => ctx.Projection);
57+
58+
// Multiple properties can be included using anonymous types
59+
AddNavigationField(
60+
name: "child1",
61+
projection: _ => new { _.Child1, _.Child2 },
62+
resolve: ctx => ctx.Projection.Child1);
63+
64+
// Nested navigation paths are also supported
65+
AddNavigationField(
66+
name: "level3Entity",
67+
projection: _ => _.Level2Entity.Level3Entity,
68+
resolve: ctx => ctx.Projection);
69+
```
70+
71+
The projection expression provides type-safety and automatically extracts the include names from the accessed properties.
5072

5173

5274
## Projections
@@ -146,6 +168,55 @@ public class OrderGraph :
146168
Without automatic foreign key inclusion, `context.Source.CustomerId` would be `0` (or `Guid.Empty` for Guid keys) if `customerId` wasn't explicitly requested in the GraphQL query, causing the query to fail.
147169

148170

171+
### Using Projection-Based Resolve
172+
173+
When using `Field().Resolve()` or `Field().ResolveAsync()` in graph types, navigation properties on `context.Source` may not be loaded if the projection system didn't include them. To safely access navigation properties in custom resolvers, use the projection-based extension methods:
174+
175+
```cs
176+
public class ChildGraphType : EfObjectGraphType<IntegrationDbContext, ChildEntity>
177+
{
178+
public ChildGraphType(IEfGraphQLService<IntegrationDbContext> graphQlService) :
179+
base(graphQlService) =>
180+
Field<int>("ParentId")
181+
.Resolve<IntegrationDbContext, ChildEntity, int, ParentEntity>(
182+
projection: x => x.Parent,
183+
resolve: ctx => ctx.Projection.Id);
184+
}
185+
```
186+
187+
**Available Extension Methods:**
188+
189+
- `Resolve<TDbContext, TSource, TReturn, TProjection>()` - Synchronous resolver with projection
190+
- `ResolveAsync<TDbContext, TSource, TReturn, TProjection>()` - Async resolver with projection
191+
- `ResolveList<TDbContext, TSource, TReturn, TProjection>()` - List resolver with projection
192+
- `ResolveListAsync<TDbContext, TSource, TReturn, TProjection>()` - Async list resolver with projection
193+
194+
The projection-based extension methods ensure required data is loaded by:
195+
196+
1. Storing projection metadata in field metadata
197+
2. Compiling the projection expression for runtime execution
198+
3. Applying the projection to `context.Source` before calling the resolver
199+
4. Providing the projected data via `ResolveProjectionContext<TDbContext, TProjection>`
200+
201+
**Problematic Pattern (Navigation Property May Be Null):**
202+
203+
```cs
204+
Field<int>("ParentId")
205+
.Resolve(context => context.Source.Parent.Id); // Parent may be null
206+
```
207+
208+
**Safe Pattern (Projection Ensures Data Is Loaded):**
209+
210+
```cs
211+
Field<int>("ParentId")
212+
.Resolve<IntegrationDbContext, ChildEntity, int, ParentEntity>(
213+
projection: x => x.Parent,
214+
resolve: ctx => ctx.Projection.Id); // Parent is guaranteed to be loaded
215+
```
216+
217+
**Note:** A Roslyn analyzer (GQLEF002) warns at compile time when `Field().Resolve()` accesses properties other than primary keys and foreign keys. Only PK and FK properties are guaranteed to be loaded by the projection system - all other properties (including regular scalars like `Name`) require projection-based extension methods to ensure they are loaded.
218+
219+
149220
### When Projections Are Not Used
150221

151222
Projections are bypassed and the full entity is loaded in these cases:
@@ -213,16 +284,17 @@ public class CompanyGraph :
213284
{
214285
AddNavigationListField(
215286
name: "employees",
216-
resolve: _ => _.Source.Employees);
287+
projection: _ => _.Employees,
288+
resolve: _ => _.Projection);
217289
AddNavigationConnectionField(
218290
name: "employeesConnection",
219-
resolve: _ => _.Source.Employees,
220-
includeNames: ["Employees"]);
291+
projection: _ => _.Employees,
292+
resolve: _ => _.Projection);
221293
AutoMap();
222294
}
223295
}
224296
```
225-
<sup><a href='/src/Snippets/TypedGraph.cs#L5-L24' title='Snippet source file'>snippet source</a> | <a href='#snippet-typedGraph' title='Start of snippet'>anchor</a></sup>
297+
<sup><a href='/src/Snippets/TypedGraph.cs#L5-L25' title='Snippet source file'>snippet source</a> | <a href='#snippet-typedGraph' title='Start of snippet'>anchor</a></sup>
226298
<!-- endSnippet -->
227299

228300

@@ -340,10 +412,11 @@ public class CompanyGraph :
340412
base(graphQlService) =>
341413
AddNavigationConnectionField(
342414
name: "employees",
343-
resolve: _ => _.Source.Employees);
415+
projection: _ => _.Employees,
416+
resolve: _ => _.Projection);
344417
}
345418
```
346-
<sup><a href='/src/Snippets/ConnectionTypedGraph.cs#L5-L17' title='Snippet source file'>snippet source</a> | <a href='#snippet-ConnectionTypedGraph' title='Start of snippet'>anchor</a></sup>
419+
<sup><a href='/src/Snippets/ConnectionTypedGraph.cs#L5-L18' title='Snippet source file'>snippet source</a> | <a href='#snippet-ConnectionTypedGraph' title='Start of snippet'>anchor</a></sup>
347420
<!-- endSnippet -->
348421

349422

docs/filters.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ Filters can project through navigation properties to access related entity data:
294294
```cs
295295
var filters = new Filters<MyDbContext>();
296296
filters.For<Order>().Add(
297-
projection: o => new { o.TotalAmount, o.Customer.IsActive },
297+
projection: _ => new { _.TotalAmount, _.Customer.IsActive },
298298
filter: (_, _, _, x) => x.TotalAmount >= 100 && x.IsActive);
299299
EfGraphQLConventions.RegisterInContainer<MyDbContext>(
300300
services,

docs/mdsource/defining-graphs.source.md

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,29 @@ context.Heros
3939
.Include("Friends.Address");
4040
```
4141

42-
The string for the include is taken from the field name when using `AddNavigationField` or `AddNavigationConnectionField` with the first character upper cased. This value can be overridden using the optional parameter `includeNames` . Note that `includeNames` is an `IEnumerable<string>` so that multiple navigation properties can optionally be included for a single node.
42+
The string for the include is taken from the field name when using `AddNavigationField` or `AddNavigationConnectionField` with the first character upper cased. This can be customized using a projection expression:
43+
44+
```cs
45+
// Using projection to specify which navigation properties to include
46+
AddNavigationConnectionField(
47+
name: "employeesConnection",
48+
projection: _ => _.Employees,
49+
resolve: ctx => ctx.Projection);
50+
51+
// Multiple properties can be included using anonymous types
52+
AddNavigationField(
53+
name: "child1",
54+
projection: _ => new { _.Child1, _.Child2 },
55+
resolve: ctx => ctx.Projection.Child1);
56+
57+
// Nested navigation paths are also supported
58+
AddNavigationField(
59+
name: "level3Entity",
60+
projection: _ => _.Level2Entity.Level3Entity,
61+
resolve: ctx => ctx.Projection);
62+
```
63+
64+
The projection expression provides type-safety and automatically extracts the include names from the accessed properties.
4365

4466

4567
## Projections
@@ -87,6 +109,55 @@ snippet: ProjectionCustomResolver
87109
Without automatic foreign key inclusion, `context.Source.CustomerId` would be `0` (or `Guid.Empty` for Guid keys) if `customerId` wasn't explicitly requested in the GraphQL query, causing the query to fail.
88110

89111

112+
### Using Projection-Based Resolve
113+
114+
When using `Field().Resolve()` or `Field().ResolveAsync()` in graph types, navigation properties on `context.Source` may not be loaded if the projection system didn't include them. To safely access navigation properties in custom resolvers, use the projection-based extension methods:
115+
116+
```cs
117+
public class ChildGraphType : EfObjectGraphType<IntegrationDbContext, ChildEntity>
118+
{
119+
public ChildGraphType(IEfGraphQLService<IntegrationDbContext> graphQlService) :
120+
base(graphQlService) =>
121+
Field<int>("ParentId")
122+
.Resolve<IntegrationDbContext, ChildEntity, int, ParentEntity>(
123+
projection: x => x.Parent,
124+
resolve: ctx => ctx.Projection.Id);
125+
}
126+
```
127+
128+
**Available Extension Methods:**
129+
130+
- `Resolve<TDbContext, TSource, TReturn, TProjection>()` - Synchronous resolver with projection
131+
- `ResolveAsync<TDbContext, TSource, TReturn, TProjection>()` - Async resolver with projection
132+
- `ResolveList<TDbContext, TSource, TReturn, TProjection>()` - List resolver with projection
133+
- `ResolveListAsync<TDbContext, TSource, TReturn, TProjection>()` - Async list resolver with projection
134+
135+
The projection-based extension methods ensure required data is loaded by:
136+
137+
1. Storing projection metadata in field metadata
138+
2. Compiling the projection expression for runtime execution
139+
3. Applying the projection to `context.Source` before calling the resolver
140+
4. Providing the projected data via `ResolveProjectionContext<TDbContext, TProjection>`
141+
142+
**Problematic Pattern (Navigation Property May Be Null):**
143+
144+
```cs
145+
Field<int>("ParentId")
146+
.Resolve(context => context.Source.Parent.Id); // Parent may be null
147+
```
148+
149+
**Safe Pattern (Projection Ensures Data Is Loaded):**
150+
151+
```cs
152+
Field<int>("ParentId")
153+
.Resolve<IntegrationDbContext, ChildEntity, int, ParentEntity>(
154+
projection: x => x.Parent,
155+
resolve: ctx => ctx.Projection.Id); // Parent is guaranteed to be loaded
156+
```
157+
158+
**Note:** A Roslyn analyzer (GQLEF002) warns at compile time when `Field().Resolve()` accesses properties other than primary keys and foreign keys. Only PK and FK properties are guaranteed to be loaded by the projection system - all other properties (including regular scalars like `Name`) require projection-based extension methods to ensure they are loaded.
159+
160+
90161
### When Projections Are Not Used
91162

92163
Projections are bypassed and the full entity is loaded in these cases:

src/Benchmarks/SimpleQueryBenchmark.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ public ParentGraphType(IEfGraphQLService<BenchmarkDbContext> graphQlService) :
3838
{
3939
AddNavigationListField(
4040
name: "children",
41-
resolve: _ => _.Source.Children,
42-
includeNames: ["Children"]);
41+
projection: _ => _.Children,
42+
resolve: _ => _.Projection);
4343
AutoMap();
4444
}
4545
}

src/Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<Project>
33
<PropertyGroup>
44
<NoWarn>CS1591;NU5104;CS1573;CS9107;NU1608;NU1109</NoWarn>
5-
<Version>34.0.0-beta.4</Version>
5+
<Version>34.0.0</Version>
66
<LangVersion>preview</LangVersion>
77
<AssemblyVersion>1.0.0</AssemblyVersion>
88
<PackageTags>EntityFrameworkCore, EntityFramework, GraphQL</PackageTags>

src/Directory.Packages.props

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
<PackageVersion Include="GraphQL.SystemTextJson" Version="8.8.3" />
1616
<PackageVersion Include="MarkdownSnippets.MsBuild" Version="28.0.0-beta.25" />
1717
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2" />
18+
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
19+
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" />
1820
<PackageVersion Include="Microsoft.Data.SqlClient" Version="6.1.4" />
1921
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.2" />
2022
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.2" />

0 commit comments

Comments
 (0)