Skip to content

Expose the projection, and project get only mapped properties - #1439

Merged
SimonCropp merged 1 commit into
mainfrom
apply-projection-api
Oct 9, 2026
Merged

SimonCropp merged 1 commit into
mainfrom
apply-projection-api

Conversation

@SimonCropp

Copy link
Copy Markdown
Owner

Breaking changes

  • IEfGraphQLService<TDbContext> has a new member, ApplyProjection. An implementation of the interface other than the library's no longer compiles. Code that only calls the interface is unaffected.
  • A query that selects a mapped get-only property is now projected. It used to load the whole entity. A resolver that reads a property of its source without declaring it worked in such a query, and now reads a default value, as it already did in any query that did not select a get-only property. Declare what a resolver reads with WithProjection or a projection-based resolve.

ApplyProjection

The query fields apply the select projection themselves. A resolver that builds a query of its own had no way to, so a mutation reading back the entity it returns had to build Include chains by hand from the selection:

DbSet<Invitation>()
    .Include("AdviceStateView")
    .Include("Attachments")
    .Include("Attachments.ModifiedBy")
    ... 21 includes, up to 4 deep
    .AsSplitQuery()
    .Single(...)

That loads every column of every included entity, applies the arguments of navigation fields in memory, and gives the resolvers of the returned entity a different entity to the one a query gives them.

var query = data.Orders
    .AsSplitQuery()
    .Where(_ => _.Id == id);
return await GraphQlService.ApplyProjection(context, query).SingleAsync();

ApplyProjection narrows the query to what the request selected under the field being resolved: the select a query field would build, or includes where the entity cannot be projected.

Get-only mapped properties

A select cannot assign a property with no setter, so selecting one gave up on the select and loaded the whole entity, and everything under it, through includes. LastModified { get; } as a computed column is a common case, and selecting it turned any query into a whole-entity load.

A property with no setter that is mapped in the model is now bound through the field Entity Framework writes it through:

-- before: every column
select r.Id, r.Age, r.ComputedInDb, r.FirstName, r.LastName, r.ReadOnlyParentId
-- after: what was selected, and the keys
select r.ComputedInDb as <ComputedInDb>k__BackingField, r.FirstName, r.Id, r.LastName, r.ReadOnlyParentId

An expression-bodied property has no field and no column, so selecting one still falls back, as does a get-only property that is not mapped.

Tests

  • Custom_mutation_reads_back_with_projection: a mutation with its own resolver reads its entity back through ApplyProjection, with a select and the children's orderBy applied by the database.
  • Query_database_computed_column_readonly_property, Single_query_with_collection_navigation_to_readonly_entity and the two Connection_with_read_only_property_* tests now show a select in place of the whole entity.
  • Readonly_root_with_navigation_falls_back_to_include and Collection_navigation_to_entity_with_readonly_property_falls_back_to_full_include select an expression-bodied property instead of the computed column, so they still cover the fallback they are named for.

Docs

docs/defining-graphs.md gains sections on read-only properties, abstract types and applying the projection to a custom query, and "When Projections Are Not Used" is rewritten: it said read-only properties and abstract types always bypass the projection.

IEfGraphQLService gains ApplyProjection, which narrows a query to what
the request selected under the field being resolved, the way the query
fields do. A resolver that builds a query of its own, such as a mutation
reading back the entity it returns, had no way to do this, and built
Include chains by hand: every column of every included entity, and one
include per selected navigation.

A mapped property with no setter, such as a database computed column,
is now bound through the field EF writes it through. A select cannot
assign a property with no setter, so 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.

Breaking:

 * IEfGraphQLService<TDbContext> has a new member, so an implementation
   of it other than the library's no longer compiles.
 * A query that selects a mapped get only property is now projected. A
   resolver reading a property of its source that it has not declared
   used to work in such a query, since the whole entity was loaded. It
   now reads a default, as it already did in any other query.

Bump version to 36.0.0
@SimonCropp
SimonCropp merged commit a6e7a00 into main Oct 9, 2026
3 checks passed
@SimonCropp
SimonCropp deleted the apply-projection-api branch October 9, 2026 04:53
@SimonCropp SimonCropp added this to the 36.0.0 milestone Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant