Skip to content
Closed
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
75 changes: 75 additions & 0 deletions claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,29 @@ The README.md and docs/*.md files are auto-generated from source files using [Ma
- Executed after EF query to determine if nodes should be included in results
- Useful when filter criteria don't exist in the database

**ProjectedField API** (`src/GraphQL.EntityFramework/GraphApi/EfGraphQLService_Projected.cs`)
- Provides methods for fields that project and transform entity properties
- Three main methods:
- `AddProjectedField` - For query-level projected fields
- `AddProjectedNavigationField` - For single navigation property projections
- `AddProjectedNavigationListField` - For collection navigation property projections
- Each accepts:
- `projection` - Expression that selects required properties from entity
- `transform` - Function that transforms projected data to final result
- Supports both sync and async transforms
- Supports context-aware transforms that receive ResolveEfFieldContext
- Use `includeNames` parameter to specify which properties/navigations to load
- Note: Current implementation has limitations with scalar property projection on source entities

**Roslyn Analyzer** (`src/GraphQL.EntityFramework.Analyzers/`)
- Compile-time code analyzer packaged with the library
- Diagnostic ID: GQLEF001
- Detects problematic `context.Source.PropertyName` access patterns
- Only analyzes code in EfObjectGraphType, EfInterfaceGraphType, and QueryGraphType classes
- Allows `Id` and `*Id` properties (primary keys and foreign keys)
- Warns on other property access, suggesting use of ProjectedField API
- Packaged in NuGet at `analyzers/dotnet/cs` directory

### Include Resolution

The library automatically determines EF includes by interrogating the incoming GraphQL query. When a navigation property is requested in a GraphQL query, the corresponding EF Include is automatically added to the query. This is handled by:
Expand Down Expand Up @@ -136,6 +159,58 @@ Arguments are processed in order: ids → where → orderBy → skip → take

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

### ProjectedField Usage

The ProjectedField API provides a way to explicitly project and transform entity properties:

**Simple Transform:**
```csharp
AddProjectedNavigationField<ParentEntity, string?, string>(
name: "propertyUpper",
resolve: _ => _.Source,
projection: entity => entity.Property,
transform: property => property?.ToUpper() ?? "",
includeNames: ["Property"]);
```

**Context-Aware Transform:**
```csharp
AddProjectedNavigationField<ParentEntity, string?, string>(
name: "propertyWithContext",
resolve: _ => _.Source,
projection: entity => entity.Property,
transform: (context, property) => {
var userId = context.User?.FindFirst("sub")?.Value;
return $"{userId}: {property ?? "null"}";
},
includeNames: ["Property"]);
```

**Async Transform:**
```csharp
AddProjectedNavigationField<ParentEntity, string?, string>(
name: "enrichedProperty",
resolve: _ => _.Source,
projection: entity => entity.Property,
transform: async property => {
var result = await _externalService.EnrichAsync(property);
return result;
},
includeNames: ["Property"]);
```

**List Field:**
```csharp
AddProjectedNavigationListField<ChildEntity, string?, string>(
name: "childrenProperties",
resolve: _ => _.Source.Children,
projection: child => child.Property,
transform: property => property ?? "empty",
includeNames: ["Children", "Children.Property"]);
```

**Note:** The `includeNames` parameter is critical for ensuring EF loads the required properties/navigations.

## Testing

Tests use:
Expand Down
260 changes: 260 additions & 0 deletions docs/defining-graphs.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,266 @@ public class CompanyGraph :
<!-- endSnippet -->


## Projected Fields

The ProjectedField API provides a way to explicitly project and transform entity properties in GraphQL fields. Use cases include:

- Transform scalar properties (e.g., converting to uppercase, formatting dates)
- Apply context-aware transformations (e.g., filtering based on user permissions)
- Perform async operations on projected data
- Ensure specific properties are loaded from the database

**Note:** The Roslyn analyzer (GQLEF001) will warn when accessing `context.Source.PropertyName` directly, suggesting use of ProjectedField methods instead.


### Understanding the Two Parameters

ProjectedField methods accept two key parameters that work together to safely access entity properties:

**1. `projection`** - Specifies the complete path to the data
- An Expression showing the full navigation path from `source` to the target data
- For navigation fields: `source => source.Property` or `source => source.Navigation.Property`
- For list fields: Uses a `navigation` expression to get the collection, then a `projection` for each item
- Automatically detects and includes all navigation properties in the path
- Gets compiled once at registration time for efficiency

**2. `transform`** - Transforms the projected data into the final GraphQL field value
- Receives the projected data (not the full entity)
- Can perform calculations, formatting, async operations, etc.
- Can optionally access the GraphQL context for context-aware transformations

**Execution flow:**

```csharp
// 1. PROJECTION - Extract needed data from source (navigations auto-included)
var projectedData = compiledProjection(context.Source); // e.g., extracts source.Property

// 2. Apply filters (if any)
if (!ShouldInclude(context.Source)) return default;

// 3. TRANSFORM - Create final value
var result = await transform(fieldContext, projectedData); // e.g., ToUpper()
return result;
```

This approach ensures that all required navigation properties are automatically eager-loaded from the database before the transform runs, solving the problem where `context.Source.PropertyName` may be null if not included in the GraphQL query projection.


### Basic Transform

<!-- snippet: ProjectedFieldBasicTransform -->
<a id='snippet-ProjectedFieldBasicTransform'></a>
```cs
AddProjectedField<string?, string>(
name: "propertyUpper",
projection: source => source.Property,
transform: property => property?.ToUpper() ?? "");
```
<sup><a href='/src/Tests/IntegrationTests/Graphs/ParentGraphType.cs#L17-L24' title='Snippet source file'>snippet source</a> | <a href='#snippet-ProjectedFieldBasicTransform' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


### Async Transform

<!-- snippet: ProjectedFieldAsyncTransform -->
<a id='snippet-ProjectedFieldAsyncTransform'></a>
```cs
AddProjectedField<string?, string>(
name: "propertyUpperAsync",
projection: source => source.Property,
transform: async property =>
{
await Task.Yield();
return property?.ToUpper() ?? "";
});
```
<sup><a href='/src/Tests/IntegrationTests/Graphs/ParentGraphType.cs#L26-L37' title='Snippet source file'>snippet source</a> | <a href='#snippet-ProjectedFieldAsyncTransform' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


### List Field

<!-- snippet: ProjectedFieldListField -->
<a id='snippet-ProjectedFieldListField'></a>
```cs
AddProjectedListField<string?, string>(
name: "childrenProperties",
projection: source => source.Children.Select(c => c.Property),
transform: property => property ?? "empty");
```
<sup><a href='/src/Tests/IntegrationTests/Graphs/ParentGraphType.cs#L45-L52' title='Snippet source file'>snippet source</a> | <a href='#snippet-ProjectedFieldListField' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


### Nested Navigation

<!-- snippet: ProjectedFieldNestedNavigation -->
<a id='snippet-ProjectedFieldNestedNavigation'></a>
```cs
AddProjectedField<string?, string>(
name: "level2Property",
projection: source => source.Level2Entity!.Level3Entity!.Property,
transform: property => property ?? "none");
```
<sup><a href='/src/Tests/IntegrationTests/Graphs/Levels/Level1GraphType.cs#L7-L14' title='Snippet source file'>snippet source</a> | <a href='#snippet-ProjectedFieldNestedNavigation' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

**Automatic Include Detection:**

The projection expression automatically detects and includes all navigation properties in the path. No manual specification is required.

In the example above:
- `projection: source => source.Level2Entity.Level3Entity.Property`
- Automatically detects: `Level2Entity` and `Level3Entity`
- Automatically adds includes: `["Level2Entity", "Level2Entity.Level3Entity"]`
- EF Core eager-loads both navigations before the projection executes

This automatic detection ensures all required navigation properties are eager-loaded without any manual configuration.


### Single/First Entity Queries

For single entity queries with projection and transform support, use `AddProjectedSingleField` or `AddProjectedFirstField`:

```cs
public class Query : QueryGraphType<SampleDbContext>
{
public Query(IEfGraphQLService<SampleDbContext> graphQlService) : base(graphQlService)
{
// AddProjectedSingleField - returns single entity (throws if multiple match)
AddProjectedSingleField<Order, OrderProjection, OrderDto>(
name: "order",
resolve: context => context.DbContext.Orders.Where(o => o.Id == context.GetArgument<Guid>("id")),
projection: entity => new OrderProjection
{
Id = entity.Id,
OrderNumber = entity.OrderNumber,
CustomerName = entity.Customer.Name
},
transform: projected => new OrderDto
{
Id = projected.Id,
DisplayNumber = $"ORD-{projected.OrderNumber}",
Customer = projected.CustomerName
});

// AddProjectedFirstField - returns first match (or null)
AddProjectedFirstField<Order, OrderProjection, OrderDto>(
name: "latestOrder",
resolve: context => context.DbContext.Orders.OrderByDescending(o => o.CreatedAt),
projection: entity => new OrderProjection
{
Id = entity.Id,
OrderNumber = entity.OrderNumber,
CustomerName = entity.Customer.Name
},
transform: projected => new OrderDto
{
Id = projected.Id,
DisplayNumber = $"ORD-{projected.OrderNumber}",
Customer = projected.CustomerName
},
nullable: true);
}
}
```

Both methods support:
- Sync and async transforms
- Context-aware transforms (access to `ResolveEfFieldContext`)
- `nullable` parameter (default: false) - controls whether null results are allowed or throw an exception
- `idOnly` parameter - when true, only supports the `id` query argument


### Query Fields (List Results)

For query fields returning multiple entities with projection and transform support, use `AddProjectedQueryField`:

```cs
public class Query : QueryGraphType<SampleDbContext>
{
public Query(IEfGraphQLService<SampleDbContext> graphQlService) : base(graphQlService)
{
AddProjectedQueryField<Order, OrderProjection, OrderDto>(
name: "orders",
resolve: context => context.DbContext.Orders,
projection: entity => new OrderProjection
{
Id = entity.Id,
OrderNumber = entity.OrderNumber,
CustomerName = entity.Customer.Name,
TotalAmount = entity.TotalAmount
},
transform: projected => new OrderDto
{
Id = projected.Id,
DisplayNumber = $"ORD-{projected.OrderNumber}",
Customer = projected.CustomerName,
FormattedTotal = projected.TotalAmount.ToString("C")
});
}
}
```


### Projected Connection Fields

For pageable fields with projection and transform support, use `AddProjectedNavigationConnectionField` (for navigation properties) or `AddProjectedQueryConnectionField` (for root queries):

```cs
// Root query with projected pagination
public class Query : QueryGraphType<SampleDbContext>
{
public Query(IEfGraphQLService<SampleDbContext> graphQlService) : base(graphQlService)
{
AddProjectedQueryConnectionField<Order, OrderProjection, OrderDto>(
name: "ordersConnection",
resolve: context => context.DbContext.Orders.OrderBy(o => o.CreatedAt),
projection: entity => new OrderProjection
{
Id = entity.Id,
OrderNumber = entity.OrderNumber,
CustomerName = entity.Customer.Name
},
transform: projected => new OrderDto
{
Id = projected.Id,
DisplayNumber = $"ORD-{projected.OrderNumber}",
Customer = projected.CustomerName
});
}
}

// Navigation property with projected pagination
public class CustomerGraph : EfObjectGraphType<SampleDbContext, Customer>
{
public CustomerGraph(IEfGraphQLService<SampleDbContext> graphQlService) : base(graphQlService)
{
AddProjectedNavigationConnectionField<Order, OrderProjection, OrderDto>(
name: "ordersConnection",
navigation: customer => customer.Orders,
projection: entity => new OrderProjection
{
Id = entity.Id,
OrderNumber = entity.OrderNumber,
TotalAmount = entity.TotalAmount
},
transform: projected => new OrderDto
{
Id = projected.Id,
DisplayNumber = $"ORD-{projected.OrderNumber}",
FormattedTotal = projected.TotalAmount.ToString("C")
});
}
}
```

All projected connection fields return a `ConnectionBuilder` for further customization and support:
- Sync and async transforms
- Context-aware transforms (access to `ResolveEfFieldContext`)
- Standard connection pagination (first/after, last/before)


## Connections

Creating a page-able field is supported through [GraphQL Connections](https://graphql.org/learn/pagination/) by calling `IEfGraphQLService.AddNavigationConnectionField` (for an EF navigation property), or `IEfGraphQLService.AddQueryConnectionField` (for an IQueryable). Alternatively convenience methods are exposed on the types `EfObjectGraphType` or `EfObjectGraphType<TSource>` for root or nested graphs respectively.
Expand Down
Loading