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
90 changes: 83 additions & 7 deletions docs/defining-graphs.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ public class Order
public string InternalNotes { get; set; } = null!;
}
```
<sup><a href='/src/Snippets/ProjectionSnippets.cs#L5-L22' title='Snippet source file'>snippet source</a> | <a href='#snippet-ProjectionEntity' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/Snippets/ProjectionSnippets.cs#L7-L24' title='Snippet source file'>snippet source</a> | <a href='#snippet-ProjectionEntity' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

And this GraphQL query:
Expand Down Expand Up @@ -135,7 +135,7 @@ static void ProjectionExample(MyDbContext context) =>
OrderNumber = o.OrderNumber
});
```
<sup><a href='/src/Snippets/ProjectionSnippets.cs#L24-L37' title='Snippet source file'>snippet source</a> | <a href='#snippet-ProjectionExpression' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/Snippets/ProjectionSnippets.cs#L26-L39' title='Snippet source file'>snippet source</a> | <a href='#snippet-ProjectionExpression' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

Note that `TotalAmount` and `InternalNotes` are **not** loaded from the database since they weren't requested.
Expand Down Expand Up @@ -166,7 +166,7 @@ public class OrderGraph :
});
}
```
<sup><a href='/src/Snippets/ProjectionSnippets.cs#L39-L59' title='Snippet source file'>snippet source</a> | <a href='#snippet-ProjectionCustomResolver' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/Snippets/ProjectionSnippets.cs#L41-L61' title='Snippet source file'>snippet source</a> | <a href='#snippet-ProjectionCustomResolver' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

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.
Expand Down Expand Up @@ -264,14 +264,90 @@ loaded. This suits a field whose value comes from several checks, each declaring
| Fields resolved via reflection or external logic | `WithProjection` |


### Read-Only Properties

A property with no setter that is mapped in the model, such as a database computed column, is projected like any other column. A select cannot assign a property that has no setter, so it assigns the field Entity Framework itself writes the property through.

<!-- snippet: ProjectionReadOnlyProperty -->
<a id='snippet-ProjectionReadOnlyProperty'></a>
```cs
public class Invoice
{
public int Id { get; set; }
public decimal Amount { get; set; }

// Mapped, with no setter: a computed column. Projected like any other column.
public DateTimeOffset LastModified { get; }

// Expression bodied: not a column. Selecting it loads the whole entity.
public string Summary => $"Invoice {Id} for {Amount}";
}
```
<sup><a href='/src/Snippets/ProjectionSnippets.cs#L63-L77' title='Snippet source file'>snippet source</a> | <a href='#snippet-ProjectionReadOnlyProperty' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

Selecting `lastModified` loads that one column. Entity Framework does not map a property with no setter by convention, so this applies to one that has been configured in the model, for example with `HasComputedColumnSql`. Selecting `summary` cannot be done by a select: see [When Projections Are Not Used](#when-projections-are-not-used).


### Abstract Types

A query for an abstract entity type, or a navigation to one, is projected too. No row is ever of the abstract type itself, so each row is created as its concrete derived type, with only the requested columns. The same is done for a concrete type that has derived types, so a row keeps its derived type.


### Applying the Projection to a Custom Query

The query fields (`AddQueryField`, `AddSingleField`, `AddFirstField` and `AddQueryConnectionField`) apply the projection themselves. A resolver that builds a query of its own, such as a mutation reading back the entity it returns, applies the same projection with `IEfGraphQLService.ApplyProjection`:

<!-- snippet: ProjectionApplyToQuery -->
<a id='snippet-ProjectionApplyToQuery'></a>
```cs
public class Mutation :
QueryGraphType<MyDbContext>
{
public Mutation(IEfGraphQLService<MyDbContext> graphQlService) :
base(graphQlService) =>
Field<OrderGraph>("cancelOrder")
.Argument<NonNullGraphType<IntGraphType>>("id")
.ResolveAsync(async context =>
{
var id = context.GetArgument<int>("id");
var data = ResolveDbContext(context);

var order = await data.Orders.SingleAsync(_ => _.Id == id);
order.InternalNotes = "Cancelled";
await data.SaveChangesAsync();

// Read back only what the request selected under cancelOrder
var query = data.Orders
.AsSplitQuery()
.Where(_ => _.Id == id);
return await GraphQlService.ApplyProjection(context, query).SingleAsync();
});
}
```
<sup><a href='/src/Snippets/ProjectionSnippets.cs#L79-L105' title='Snippet source file'>snippet source</a> | <a href='#snippet-ProjectionApplyToQuery' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

`ApplyProjection` narrows the query to what the request selected under the field being resolved. It builds the select a query field would build: the keys and foreign keys, the selected columns, what the fields declare through `WithProjection` and the projection-based resolve methods, and the navigations, with the `ids`, `where` and `orderBy` arguments of navigation fields applied by the database. Where the entity cannot be projected it adds includes instead.

* The field being resolved has to return the graph type of the entity the query returns.
* Add any `Where` to the query before calling `ApplyProjection`, and execute what it returns as it is.
* Call `AsSplitQuery` on the query if the request might select a collection. It is removed when the request selects none.

Loading the entity with hand-built `Include` calls instead loads every column of every included entity, and gives the resolvers of the returned entity a different entity to the one a query gives them.


### When Projections Are Not Used

Projections are bypassed and the full entity is loaded in these cases:
The select is given up on, and the entity loaded whole through includes, when the request selects something a select cannot bind:

1. **A read-only property that is not a column** - an expression-bodied property, or any other property with no setter that is not mapped in the model.
2. **A key that cannot be assigned** - a key property with no setter, or with a setter that does more than assign.
3. **An abstract type with no concrete derived type in the model.**

1. **Read-only computed properties** - When any property has no setter or is expression-bodied (at any level, including nested navigations)
2. **Abstract entity types** - When the root entity type or any navigation property type is abstract
When this happens under a navigation, that navigation is loaded whole and the rest of the query is still projected.

In these cases, the query falls back to loading the complete entity with all its properties.
Do not rely on this to load what a resolver reads. Whether it happens depends on what else the request selects, so a resolver that reads a property it has not declared can work in one query and read a default value in the next. Declare what a resolver reads with a projection.


### Performance Benefits
Expand Down
40 changes: 36 additions & 4 deletions docs/mdsource/defining-graphs.source.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,14 +201,46 @@ loaded. This suits a field whose value comes from several checks, each declaring
| Fields resolved via reflection or external logic | `WithProjection` |


### Read-Only Properties

A property with no setter that is mapped in the model, such as a database computed column, is projected like any other column. A select cannot assign a property that has no setter, so it assigns the field Entity Framework itself writes the property through.

snippet: ProjectionReadOnlyProperty

Selecting `lastModified` loads that one column. Entity Framework does not map a property with no setter by convention, so this applies to one that has been configured in the model, for example with `HasComputedColumnSql`. Selecting `summary` cannot be done by a select: see [When Projections Are Not Used](#when-projections-are-not-used).


### Abstract Types

A query for an abstract entity type, or a navigation to one, is projected too. No row is ever of the abstract type itself, so each row is created as its concrete derived type, with only the requested columns. The same is done for a concrete type that has derived types, so a row keeps its derived type.


### Applying the Projection to a Custom Query

The query fields (`AddQueryField`, `AddSingleField`, `AddFirstField` and `AddQueryConnectionField`) apply the projection themselves. A resolver that builds a query of its own, such as a mutation reading back the entity it returns, applies the same projection with `IEfGraphQLService.ApplyProjection`:

snippet: ProjectionApplyToQuery

`ApplyProjection` narrows the query to what the request selected under the field being resolved. It builds the select a query field would build: the keys and foreign keys, the selected columns, what the fields declare through `WithProjection` and the projection-based resolve methods, and the navigations, with the `ids`, `where` and `orderBy` arguments of navigation fields applied by the database. Where the entity cannot be projected it adds includes instead.

* The field being resolved has to return the graph type of the entity the query returns.
* Add any `Where` to the query before calling `ApplyProjection`, and execute what it returns as it is.
* Call `AsSplitQuery` on the query if the request might select a collection. It is removed when the request selects none.

Loading the entity with hand-built `Include` calls instead loads every column of every included entity, and gives the resolvers of the returned entity a different entity to the one a query gives them.


### When Projections Are Not Used

Projections are bypassed and the full entity is loaded in these cases:
The select is given up on, and the entity loaded whole through includes, when the request selects something a select cannot bind:

1. **A read-only property that is not a column** - an expression-bodied property, or any other property with no setter that is not mapped in the model.
2. **A key that cannot be assigned** - a key property with no setter, or with a setter that does more than assign.
3. **An abstract type with no concrete derived type in the model.**

1. **Read-only computed properties** - When any property has no setter or is expression-bodied (at any level, including nested navigations)
2. **Abstract entity types** - When the root entity type or any navigation property type is abstract
When this happens under a navigation, that navigation is loaded whole and the rest of the query is still projected.

In these cases, the query falls back to loading the complete entity with all its properties.
Do not rely on this to load what a resolver reads. Whether it happens depends on what else the request selects, so a resolver that reads a property it has not declared can work in one query and read a default value in the next. Declare what a resolver reads with a projection.


### Performance Benefits
Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<Project>
<PropertyGroup>
<NoWarn>CS1591;NU5104;CS1573;CS9107;NU1608;NU1109</NoWarn>
<Version>35.5.1</Version>
<Version>36.0.0</Version>
<LangVersion>preview</LangVersion>
<AssemblyVersion>1.0.0</AssemblyVersion>
<PackageTags>EntityFrameworkCore, EntityFramework, GraphQL</PackageTags>
Expand Down
40 changes: 40 additions & 0 deletions src/GraphQL.EntityFramework/BackingFieldReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
static class BackingFieldReader
{
/// <summary>
/// For each entity type, the mapped properties that have no setter, with the field EF writes
/// each of them through. A get only auto property, typically a database computed column, is
/// one. A member init cannot bind a property with no setter, but it can bind the field, so the
/// select projection uses these to load such a property rather than giving up on the select.
/// An expression bodied property has no field and is not mapped, so is not among them.
/// </summary>
public static IReadOnlyDictionary<Type, IReadOnlyDictionary<string, FieldInfo>> GetBackingFields(IModel model)
{
var dictionary = new Dictionary<Type, IReadOnlyDictionary<string, FieldInfo>>();
foreach (var entityType in model.GetEntityTypes())
{
if (dictionary.ContainsKey(entityType.ClrType))
{
continue;
}

Dictionary<string, FieldInfo>? fields = null;
foreach (var property in entityType.GetProperties())
{
if (property.PropertyInfo is { CanWrite: false } propertyInfo &&
property.FieldInfo is { } field &&
field.FieldType == propertyInfo.PropertyType)
{
fields ??= new(StringComparer.OrdinalIgnoreCase);
fields[propertyInfo.Name] = field;
}
}

if (fields is not null)
{
dictionary[entityType.ClrType] = fields;
}
}

return dictionary;
}
}
6 changes: 5 additions & 1 deletion src/GraphQL.EntityFramework/GraphApi/EfGraphQLService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public EfGraphQLService(
.OrderBy(derived => Depth(derived.ClrType))
.Select(derived => derived.ClrType)
.ToList());
includeAppender = new(Navigations, keyNames, foreignKeys, derivedTypes);
includeAppender = new(Navigations, keyNames, foreignKeys, derivedTypes, BackingFieldReader.GetBackingFields(model));
}

static int Depth(Type type)
Expand Down Expand Up @@ -126,6 +126,10 @@ public TDbContext ResolveDbContext(IResolveFieldContext fieldContext)
public Filters<TDbContext>? ResolveFilters(IResolveFieldContext context) =>
resolveFilters?.Invoke(context.UserContext);

public IQueryable<TItem> ApplyProjection<TItem>(IResolveFieldContext context, IQueryable<TItem> query)
where TItem : class =>
includeAppender.ApplyProjection(context, ResolveFilters(context), query, disableTracking);

/// <summary>
/// The generated sql carries table and column names and, depending on the provider, the parameter
/// values too. These messages surface as GraphQL errors, so the sql is opt in rather than default.
Expand Down
11 changes: 11 additions & 0 deletions src/GraphQL.EntityFramework/GraphApi/IEfGraphQLService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,16 @@ public partial interface IEfGraphQLService<TDbContext> :

Filters<TDbContext>? ResolveFilters(IResolveFieldContext context);

/// <summary>
/// Narrows <paramref name="query"/> to what the request selected under the field being
/// resolved, the way the query fields do: a select projection where the entity can be
/// projected, otherwise includes. For a resolver that builds a query of its own, such as a
/// mutation reading back the entity it returns.
/// </summary>
/// <param name="context">The context of the field being resolved. The field has to return the graph type of <typeparamref name="TItem"/>.</param>
/// <param name="query">The query to narrow. Call AsSplitQuery on it if the request might select a collection; it is removed when none is.</param>
IQueryable<TItem> ApplyProjection<TItem>(IResolveFieldContext context, IQueryable<TItem> query)
where TItem : class;

public IReadOnlyDictionary<Type, IReadOnlyDictionary<string, Navigation>> Navigations { get; }
}
5 changes: 3 additions & 2 deletions src/GraphQL.EntityFramework/IncludeAppender.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
IReadOnlyDictionary<Type, IReadOnlyDictionary<string, Navigation>> navigations,
IReadOnlyDictionary<Type, List<string>> keyNames,
IReadOnlyDictionary<Type, IReadOnlySet<string>> foreignKeys,
IReadOnlyDictionary<Type, IReadOnlyList<Type>> derivedTypes)
IReadOnlyDictionary<Type, IReadOnlyList<Type>> derivedTypes,
IReadOnlyDictionary<Type, IReadOnlyDictionary<string, FieldInfo>>? backingFields = null)
{
/// <summary>
/// Narrow <paramref name="query"/> to the fields the request asked for: a select projection
Expand Down Expand Up @@ -44,7 +45,7 @@ public IQueryable<TItem> ApplyProjection<TDbContext, TItem>(
query = QuerySplitting.Remove(query);
}

if (!SelectExpressionBuilder.TryBuild<TItem>(projection, keyNames, derivedTypes, out var expression, out var includePaths, out var argumentFields))
if (!SelectExpressionBuilder.TryBuild<TItem>(projection, keyNames, derivedTypes, out var expression, out var includePaths, out var argumentFields, backingFields))
{
return ApplyTracking(AddIncludesFromProjection(query, projection), disableTracking);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,12 @@ public static bool TryBuild<TEntity>(
IReadOnlyDictionary<Type, IReadOnlyList<Type>> derivedTypes,
[NotNullWhen(true)] out Expression<Func<TEntity, TEntity>>? expression,
out IReadOnlyList<string> includePaths,
out IReadOnlyList<GraphQLField> argumentFields)
out IReadOnlyList<GraphQLField> argumentFields,
IReadOnlyDictionary<Type, IReadOnlyDictionary<string, FieldInfo>>? backingFields = null)
where TEntity : class
{
expression = null;
var state = new BuildState(keyNames, derivedTypes);
var state = new BuildState(keyNames, derivedTypes, backingFields);
includePaths = state.IncludePaths;
argumentFields = state.ArgumentFields;
var entityType = typeof(TEntity);
Expand Down Expand Up @@ -378,10 +379,19 @@ static bool TryBuildBindings(
{
if (!metadata.CanWrite)
{
// Read-only property (expression-bodied or database computed column)
// Can't use projection - return false to load full entity
bindings = null;
return false;
// A mapped property with no setter, such as a database computed column, is
// bound through the field EF writes it through. Selecting one used to give up
// on the select and load the whole entity, and everything under it, through
// includes. An expression bodied property has no field and no column, so it
// still does.
if (!state.TryGetBackingField(entityType, fieldName, out var backingField))
{
bindings = null;
return false;
}

bindings.Add(Expression.Bind(backingField, Expression.Property(source, metadata.Property)));
continue;
}

bindings.Add(Bind(source, metadata));
Expand Down Expand Up @@ -608,13 +618,22 @@ static Expression Where(Expression source, ParameterExpression navParam, Type na
/// </summary>
sealed class BuildState(
IReadOnlyDictionary<Type, List<string>> keyNames,
IReadOnlyDictionary<Type, IReadOnlyList<Type>> derivedTypes)
IReadOnlyDictionary<Type, IReadOnlyList<Type>> derivedTypes,
IReadOnlyDictionary<Type, IReadOnlyDictionary<string, FieldInfo>>? backingFields)
{
public IReadOnlyDictionary<Type, List<string>> KeyNames { get; } = keyNames;
public IReadOnlyDictionary<Type, IReadOnlyList<Type>> DerivedTypes { get; } = derivedTypes;
public List<string> IncludePaths { get; } = [];
public List<GraphQLField> ArgumentFields { get; } = [];

public bool TryGetBackingField(Type entityType, string name, [NotNullWhen(true)] out FieldInfo? field)
{
field = null;
return backingFields is not null &&
backingFields.TryGetValue(entityType, out var fields) &&
fields.TryGetValue(name, out field);
}

/// <summary>
/// A navigation bound whole is materialized as a full entity, so everything requested
/// under it has to arrive through includes. String include paths resolve navigations
Expand Down
Loading
Loading