diff --git a/docs/defining-graphs.md b/docs/defining-graphs.md index d6eaccfb..73379d89 100644 --- a/docs/defining-graphs.md +++ b/docs/defining-graphs.md @@ -105,7 +105,7 @@ public class Order public string InternalNotes { get; set; } = null!; } ``` -snippet source | anchor +snippet source | anchor And this GraphQL query: @@ -135,7 +135,7 @@ static void ProjectionExample(MyDbContext context) => OrderNumber = o.OrderNumber }); ``` -snippet source | anchor +snippet source | anchor Note that `TotalAmount` and `InternalNotes` are **not** loaded from the database since they weren't requested. @@ -166,7 +166,7 @@ public class OrderGraph : }); } ``` -snippet source | anchor +snippet source | anchor 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. @@ -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. + + + +```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}"; +} +``` +snippet source | anchor + + +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`: + + + +```cs +public class Mutation : + QueryGraphType +{ + public Mutation(IEfGraphQLService graphQlService) : + base(graphQlService) => + Field("cancelOrder") + .Argument>("id") + .ResolveAsync(async context => + { + var id = context.GetArgument("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(); + }); +} +``` +snippet source | anchor + + +`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 diff --git a/docs/mdsource/defining-graphs.source.md b/docs/mdsource/defining-graphs.source.md index 0c083613..d9e89dbb 100644 --- a/docs/mdsource/defining-graphs.source.md +++ b/docs/mdsource/defining-graphs.source.md @@ -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 diff --git a/src/Directory.Build.props b/src/Directory.Build.props index b95b7c3a..8c7d5fd8 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CS1591;NU5104;CS1573;CS9107;NU1608;NU1109 - 35.5.1 + 36.0.0 preview 1.0.0 EntityFrameworkCore, EntityFramework, GraphQL diff --git a/src/GraphQL.EntityFramework/BackingFieldReader.cs b/src/GraphQL.EntityFramework/BackingFieldReader.cs new file mode 100644 index 00000000..80f999df --- /dev/null +++ b/src/GraphQL.EntityFramework/BackingFieldReader.cs @@ -0,0 +1,40 @@ +static class BackingFieldReader +{ + /// + /// 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. + /// + public static IReadOnlyDictionary> GetBackingFields(IModel model) + { + var dictionary = new Dictionary>(); + foreach (var entityType in model.GetEntityTypes()) + { + if (dictionary.ContainsKey(entityType.ClrType)) + { + continue; + } + + Dictionary? 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; + } +} diff --git a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService.cs b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService.cs index 30867dc3..8497cff5 100644 --- a/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService.cs +++ b/src/GraphQL.EntityFramework/GraphApi/EfGraphQLService.cs @@ -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) @@ -126,6 +126,10 @@ public TDbContext ResolveDbContext(IResolveFieldContext fieldContext) public Filters? ResolveFilters(IResolveFieldContext context) => resolveFilters?.Invoke(context.UserContext); + public IQueryable ApplyProjection(IResolveFieldContext context, IQueryable query) + where TItem : class => + includeAppender.ApplyProjection(context, ResolveFilters(context), query, disableTracking); + /// /// 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. diff --git a/src/GraphQL.EntityFramework/GraphApi/IEfGraphQLService.cs b/src/GraphQL.EntityFramework/GraphApi/IEfGraphQLService.cs index 2c6756fb..f600f711 100644 --- a/src/GraphQL.EntityFramework/GraphApi/IEfGraphQLService.cs +++ b/src/GraphQL.EntityFramework/GraphApi/IEfGraphQLService.cs @@ -17,5 +17,16 @@ public partial interface IEfGraphQLService : Filters? ResolveFilters(IResolveFieldContext context); + /// + /// Narrows 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. + /// + /// The context of the field being resolved. The field has to return the graph type of . + /// The query to narrow. Call AsSplitQuery on it if the request might select a collection; it is removed when none is. + IQueryable ApplyProjection(IResolveFieldContext context, IQueryable query) + where TItem : class; + public IReadOnlyDictionary> Navigations { get; } } \ No newline at end of file diff --git a/src/GraphQL.EntityFramework/IncludeAppender.cs b/src/GraphQL.EntityFramework/IncludeAppender.cs index ede44d3d..35db6b5a 100644 --- a/src/GraphQL.EntityFramework/IncludeAppender.cs +++ b/src/GraphQL.EntityFramework/IncludeAppender.cs @@ -2,7 +2,8 @@ IReadOnlyDictionary> navigations, IReadOnlyDictionary> keyNames, IReadOnlyDictionary> foreignKeys, - IReadOnlyDictionary> derivedTypes) + IReadOnlyDictionary> derivedTypes, + IReadOnlyDictionary>? backingFields = null) { /// /// Narrow to the fields the request asked for: a select projection @@ -44,7 +45,7 @@ public IQueryable ApplyProjection( query = QuerySplitting.Remove(query); } - if (!SelectExpressionBuilder.TryBuild(projection, keyNames, derivedTypes, out var expression, out var includePaths, out var argumentFields)) + if (!SelectExpressionBuilder.TryBuild(projection, keyNames, derivedTypes, out var expression, out var includePaths, out var argumentFields, backingFields)) { return ApplyTracking(AddIncludesFromProjection(query, projection), disableTracking); } diff --git a/src/GraphQL.EntityFramework/SelectProjection/SelectExpressionBuilder.cs b/src/GraphQL.EntityFramework/SelectProjection/SelectExpressionBuilder.cs index a80c405d..b5d774d5 100644 --- a/src/GraphQL.EntityFramework/SelectProjection/SelectExpressionBuilder.cs +++ b/src/GraphQL.EntityFramework/SelectProjection/SelectExpressionBuilder.cs @@ -113,11 +113,12 @@ public static bool TryBuild( IReadOnlyDictionary> derivedTypes, [NotNullWhen(true)] out Expression>? expression, out IReadOnlyList includePaths, - out IReadOnlyList argumentFields) + out IReadOnlyList argumentFields, + IReadOnlyDictionary>? 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); @@ -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)); @@ -608,13 +618,22 @@ static Expression Where(Expression source, ParameterExpression navParam, Type na /// sealed class BuildState( IReadOnlyDictionary> keyNames, - IReadOnlyDictionary> derivedTypes) + IReadOnlyDictionary> derivedTypes, + IReadOnlyDictionary>? backingFields) { public IReadOnlyDictionary> KeyNames { get; } = keyNames; public IReadOnlyDictionary> DerivedTypes { get; } = derivedTypes; public List IncludePaths { get; } = []; public List 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); + } + /// /// 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 diff --git a/src/Snippets/ProjectionSnippets.cs b/src/Snippets/ProjectionSnippets.cs index d81e42bc..507a13c7 100644 --- a/src/Snippets/ProjectionSnippets.cs +++ b/src/Snippets/ProjectionSnippets.cs @@ -1,3 +1,5 @@ +using GraphQL; +using GraphQL.Types; using StringGraphType = GraphQL.Types.StringGraphType; class ProjectionSnippets @@ -58,11 +60,56 @@ public OrderGraph(IEfGraphQLService graphQlService) : #endregion + #region ProjectionReadOnlyProperty + + 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}"; + } + + #endregion + + #region ProjectionApplyToQuery + + public class Mutation : + QueryGraphType + { + public Mutation(IEfGraphQLService graphQlService) : + base(graphQlService) => + Field("cancelOrder") + .Argument>("id") + .ResolveAsync(async context => + { + var id = context.GetArgument("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(); + }); + } + + #endregion + public class MyDbContext : DbContext { public DbSet Orders { get; set; } = null!; public DbSet Customers { get; set; } = null!; + public DbSet Invoices { get; set; } = null!; } public class Customer diff --git a/src/Tests/IntegrationTests/IntegrationTests.Collection_navigation_to_entity_with_readonly_property_falls_back_to_full_include.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Collection_navigation_to_entity_with_readonly_property_falls_back_to_full_include.verified.txt index 6769142d..4da45630 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.Collection_navigation_to_entity_with_readonly_property_falls_back_to_full_include.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.Collection_navigation_to_entity_with_readonly_property_falls_back_to_full_include.verified.txt @@ -7,7 +7,7 @@ children: [ { firstName: John, - computedInDb: John Smith + displayName: John Smith } ] } diff --git a/src/Tests/IntegrationTests/IntegrationTests.Connection_with_read_only_property_and_navigation.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Connection_with_read_only_property_and_navigation.verified.txt index 9800a93f..ff8a7b89 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.Connection_with_read_only_property_and_navigation.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.Connection_with_read_only_property_and_navigation.verified.txt @@ -30,19 +30,16 @@ from ReadOnlyEntities as r }, { Text: -select r1.Id, - r1.Age, - r1.ComputedInDb, +select r1.ComputedInDb, r1.FirstName, - r1.LastName, - r1.ReadOnlyParentId, + r1.Id, + case when r0.Id is null then cast (1 as bit) else cast (0 as bit) end, r0.Id, - r0.Property + r0.Property, + r1.ReadOnlyParentId from (select r.Id, - r.Age, r.ComputedInDb, r.FirstName, - r.LastName, r.ReadOnlyParentId from ReadOnlyEntities as r order by r.FirstName diff --git a/src/Tests/IntegrationTests/IntegrationTests.Connection_with_read_only_property_navigation_and_fragment.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Connection_with_read_only_property_navigation_and_fragment.verified.txt index 9800a93f..ff8a7b89 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.Connection_with_read_only_property_navigation_and_fragment.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.Connection_with_read_only_property_navigation_and_fragment.verified.txt @@ -30,19 +30,16 @@ from ReadOnlyEntities as r }, { Text: -select r1.Id, - r1.Age, - r1.ComputedInDb, +select r1.ComputedInDb, r1.FirstName, - r1.LastName, - r1.ReadOnlyParentId, + r1.Id, + case when r0.Id is null then cast (1 as bit) else cast (0 as bit) end, r0.Id, - r0.Property + r0.Property, + r1.ReadOnlyParentId from (select r.Id, - r.Age, r.ComputedInDb, r.FirstName, - r.LastName, r.ReadOnlyParentId from ReadOnlyEntities as r order by r.FirstName diff --git a/src/Tests/IntegrationTests/IntegrationTests.Custom_mutation_reads_back_with_projection.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Custom_mutation_reads_back_with_projection.verified.txt new file mode 100644 index 00000000..c60a3441 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Custom_mutation_reads_back_with_projection.verified.txt @@ -0,0 +1,78 @@ +{ + target: { + Data: { + changeParentAndReadBack: { + property: Foo, + children: [ + { + property: Value3 + }, + { + property: Value2 + } + ] + } + } + }, + sql: [ + { + Text: +select top (2) p.Id, + p.Property +from ParentEntities as p +where p.Id = @id, + Parameters: { + @id: Guid_1 + } + }, + { + Text: +set implicit_transactions off; + +set nocount on; + +update ParentEntities +set Property = @p0 +output 1 +where Id = @p1, + Parameters: { + @p1: Guid_1, + @p0: { + Value: Foo, + Size: 4000, + IsNullable: true + } + } + }, + { + Text: +select top (2) p.Id, + p.Property +from ParentEntities as p +where p.Id = @id +order by p.Id, + Parameters: { + @id: Guid_1 + } + }, + { + Text: +select c1.Id, + c1.ParentId, + c1.Property, + p0.Id +from (select top (1) p.Id + from ParentEntities as p + where p.Id = @id + order by p.Id) as p0 + inner join + ChildEntities as c1 + on p0.Id = c1.ParentId +order by p0.Id, + c1.Property desc, + Parameters: { + @id: Guid_1 + } + } + ] +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Query_database_computed_column_readonly_property.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Query_database_computed_column_readonly_property.verified.txt index d91155e0..760cae5f 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.Query_database_computed_column_readonly_property.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.Query_database_computed_column_readonly_property.verified.txt @@ -17,10 +17,9 @@ }, sql: { Text: -select r.Id, - r.Age, - r.ComputedInDb, +select r.ComputedInDb as k__BackingField, r.FirstName, + r.Id, r.LastName, r.ReadOnlyParentId from ReadOnlyEntities as r diff --git a/src/Tests/IntegrationTests/IntegrationTests.Readonly_root_with_navigation_falls_back_to_include.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Readonly_root_with_navigation_falls_back_to_include.verified.txt index 657df07b..513c6770 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.Readonly_root_with_navigation_falls_back_to_include.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.Readonly_root_with_navigation_falls_back_to_include.verified.txt @@ -4,7 +4,7 @@ readOnlyEntities: [ { firstName: John, - computedInDb: John Smith, + displayName: John Smith, readOnlyParent: { property: TheParent } diff --git a/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt index 716ba229..bdc7e912 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt @@ -1957,6 +1957,7 @@ input TphDerivedNavOwnerEntityOrderBy { type Mutation { parentEntityMutation(id: ID, ids: [ID!], where: ParentEntityWhere): Parent! + changeParentAndReadBack(id: ID!): Parent } type DerivedWithNavigation implements BaseEntity { diff --git a/src/Tests/IntegrationTests/IntegrationTests.Single_query_with_collection_navigation_to_readonly_entity.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Single_query_with_collection_navigation_to_readonly_entity.verified.txt index 3c845986..8f1a24fb 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.Single_query_with_collection_navigation_to_readonly_entity.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.Single_query_with_collection_navigation_to_readonly_entity.verified.txt @@ -16,10 +16,9 @@ sql: { Text: select r1.Id, - r0.Id, - r0.Age, r0.ComputedInDb, r0.FirstName, + r0.Id, r0.LastName, r0.ReadOnlyParentId, r1.Property @@ -31,6 +30,7 @@ from (select top (2) r.Id, ReadOnlyEntities as r0 on r1.Id = r0.ReadOnlyParentId order by r1.Id, + r0.Id, Parameters: { @p1: Guid_1 } diff --git a/src/Tests/IntegrationTests/IntegrationTests.cs b/src/Tests/IntegrationTests/IntegrationTests.cs index afe7d57e..45c13c33 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.cs +++ b/src/Tests/IntegrationTests/IntegrationTests.cs @@ -1628,6 +1628,47 @@ public async Task SingleParent_Child_mutation() await RunQuery(database, query, null, null, false, [entity1, entity2, entity3, entity4, entity5]); } + // A mutation with a resolver of its own reads the entity back through ApplyProjection, so it + // loads what a query field would: the selected columns, and the children with their orderBy + // applied by the database. + [Fact] + public async Task Custom_mutation_reads_back_with_projection() + { + var query = + """ + mutation { + changeParentAndReadBack(id: "00000000-0000-0000-0000-000000000001") { + property + children(orderBy: {property: descending}) + { + property + } + } + } + """; + + var entity1 = new ParentEntity + { + Id = new("00000000-0000-0000-0000-000000000001"), + Property = "Value1" + }; + var entity2 = new ChildEntity + { + Property = "Value2", + Parent = entity1 + }; + var entity3 = new ChildEntity + { + Property = "Value3", + Parent = entity1 + }; + entity1.Children.Add(entity2); + entity1.Children.Add(entity3); + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [entity1, entity2, entity3]); + } + [Fact] public async Task FirstParent_Child_mutation() { diff --git a/src/Tests/IntegrationTests/IntegrationTests_abstract_type_include_fallback.cs b/src/Tests/IntegrationTests/IntegrationTests_abstract_type_include_fallback.cs index 562039c8..8e586ad0 100644 --- a/src/Tests/IntegrationTests/IntegrationTests_abstract_type_include_fallback.cs +++ b/src/Tests/IntegrationTests/IntegrationTests_abstract_type_include_fallback.cs @@ -117,6 +117,8 @@ ... on Derived await RunQuery(database, query, null, null, false, [parent, child]); } + // displayName is expression bodied, so the select cannot bind it and the query falls back to + // includes. A mapped get only property, such as computedInDb, no longer causes this. [Fact] public async Task Readonly_root_with_navigation_falls_back_to_include() { @@ -139,7 +141,7 @@ public async Task Readonly_root_with_navigation_falls_back_to_include() readOnlyEntities { firstName - computedInDb + displayName readOnlyParent { property diff --git a/src/Tests/IntegrationTests/IntegrationTests_readonly_navigation_fallback.cs b/src/Tests/IntegrationTests/IntegrationTests_readonly_navigation_fallback.cs index 5dd9c697..f0c8941e 100644 --- a/src/Tests/IntegrationTests/IntegrationTests_readonly_navigation_fallback.cs +++ b/src/Tests/IntegrationTests/IntegrationTests_readonly_navigation_fallback.cs @@ -1,5 +1,8 @@ public partial class IntegrationTests { + // displayName is expression bodied: it has no column to select and no field to bind, so the + // navigation is bound whole. A get only property that is mapped, such as computedInDb, is + // bound through its backing field and no longer causes this. [Fact] public async Task Collection_navigation_to_entity_with_readonly_property_falls_back_to_full_include() { @@ -26,7 +29,7 @@ public async Task Collection_navigation_to_entity_with_readonly_property_falls_b children { firstName - computedInDb + displayName } } } diff --git a/src/Tests/IntegrationTests/Mutation.cs b/src/Tests/IntegrationTests/Mutation.cs index f58516f3..d7c9ae6e 100644 --- a/src/Tests/IntegrationTests/Mutation.cs +++ b/src/Tests/IntegrationTests/Mutation.cs @@ -2,7 +2,8 @@ QueryGraphType { public Mutation(IEfGraphQLService efGraphQlService) : - base(efGraphQlService) => + base(efGraphQlService) + { AddSingleField( name: "parentEntityMutation", resolve: _ => _.DbContext.ParentEntities, @@ -11,4 +12,22 @@ public Mutation(IEfGraphQLService efGraphQlService) : entity.Property = "Foo"; return context.DbContext.SaveChangesAsync(); }); -} \ No newline at end of file + + // A resolver of its own, reading back the entity it changed with the service's projection + Field("changeParentAndReadBack") + .Argument>("id") + .ResolveAsync(async context => + { + var id = context.GetArgument("id"); + var data = ResolveDbContext(context); + var entity = await data.ParentEntities.SingleAsync(_ => _.Id == id); + entity.Property = "Foo"; + await data.SaveChangesAsync(); + + var query = data.ParentEntities + .AsSplitQuery() + .Where(_ => _.Id == id); + return await GraphQlService.ApplyProjection(context, query).SingleAsync(); + }); + } +}