Skip to content

Latest commit

 

History

History
603 lines (472 loc) · 22.1 KB

File metadata and controls

603 lines (472 loc) · 22.1 KB

Defining Graphs

Includes and Navigation properties.

Entity Framework has the concept of Navigation Properties:

A property defined on the principal and/or dependent entity that contains a reference(s) to the related entity(s).

In the context of GraphQL, Root Graph is the entry point to performing the initial EF query. Nested graphs then usually access navigation properties to return data, or perform a new EF query. New EF queries can be performed with AddQueryField and AddQueryConnectionField. Navigation properties queries are performed using AddNavigationField and AddNavigationConnectionField. For the above *ConnectionField refer to the GraphQL concept of pagination using Connections.

When performing a query there are several approaches to Loading Related Data

  • Eager loading means that the related data is loaded from the database as part of the initial query.
  • Explicit loading means that the related data is explicitly loaded from the database at a later time.
  • Lazy loading means that the related data is transparently loaded from the database when the navigation property is accessed.

Ideally, all navigation properties would be eagerly loaded as part of the root query. However determining what navigation properties to eagerly is difficult in the context of GraphQL. The reason is, given the returned hierarchy of data is dynamically defined by the requesting client, the root query cannot know what properties to include. To work around this GraphQL.EntityFramework interrogates the incoming query to derive the includes. So for example take the following query

{
  hero {
    name
    friends {
      name
      address {
        town
      }
    }
  }
}

Would result in the following query being performed

context.Heros
        .Include("Friends")
        .Include("Friends.Address");

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:

// Using projection to specify which navigation properties to include
AddNavigationConnectionField(
    name: "employeesConnection",
    projection: _ => _.Employees,
    resolve: ctx => ctx.Projection);

// Multiple properties can be included using anonymous types
AddNavigationField(
    name: "child1",
    projection: _ => new { _.Child1, _.Child2 },
    resolve: ctx => ctx.Projection.Child1);

// Nested navigation paths are also supported
AddNavigationField(
    name: "level3Entity",
    projection: _ => _.Level2Entity.Level3Entity,
    resolve: ctx => ctx.Projection);

The projection expression provides type-safety and automatically extracts the include names from the accessed properties.

Projections

GraphQL.EntityFramework automatically optimizes Entity Framework queries by using projections. When a GraphQL query is executed, only the fields explicitly requested in the query are loaded from the database, rather than loading entire entity objects.

How Projections Work

When querying entities through GraphQL, the incoming query is analyzed and a projection expression is built that includes:

  1. Primary Keys - Always included (e.g., Id)
  2. Foreign Keys - Always included automatically (e.g., ParentId, CategoryId)
  3. Requested Scalar Fields - Fields explicitly requested in the GraphQL query
  4. Navigation Properties - With their own nested projections

For example, given this entity:

public class Order
{
    // Primary key
    public int Id { get; set; }

    // Foreign key
    public int CustomerId { get; set; }

    // Navigation property
    public Customer Customer { get; set; } = null!;
    public string OrderNumber { get; set; } = null!;
    public decimal TotalAmount { get; set; }
    public string InternalNotes { get; set; } = null!;
}

snippet source | anchor

And this GraphQL query:

{
  order(id: "1") {
    id
    orderNumber
  }
}

The library will generate an EF query that projects to:

static void ProjectionExample(MyDbContext context) =>
    _ = context.Orders.Select(o => new Order
    {
        // Requested (and primary key)
        Id = o.Id,
        // Automatically included (foreign key)
        CustomerId = o.CustomerId,
        // Requested
        OrderNumber = o.OrderNumber
    });

snippet source | anchor

Note that TotalAmount and InternalNotes are not loaded from the database since they weren't requested.

Foreign Keys in Custom Resolvers

The automatic inclusion of foreign keys is useful when writing custom field resolvers. Since foreign keys are always available in the projected entity, it is safe to use them without worrying about whether they were explicitly requested:

public class OrderGraph :
    EfObjectGraphType<MyDbContext, Order>
{
    public OrderGraph(IEfGraphQLService<MyDbContext> graphQlService) :
        base(graphQlService) =>
        // Custom field that uses the foreign key
        Field<StringGraphType>("customerName")
            .ResolveAsync(async context =>
            {
                var data = ResolveDbContext(context);
                // CustomerId is available even though it wasn't in the GraphQL query
                return await data.Customers
                    .Where(c => c.Id == context.Source.CustomerId)
                    .Select(c => c.Name)
                    .SingleAsync();
            });
}

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.

Using Projection-Based Resolve

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:

public class ChildGraphType : EfObjectGraphType<IntegrationDbContext, ChildEntity>
{
    public ChildGraphType(IEfGraphQLService<IntegrationDbContext> graphQlService) :
        base(graphQlService) =>
        Field<int>("ParentId")
            .Resolve<IntegrationDbContext, ChildEntity, int, ParentEntity>(
                projection: x => x.Parent,
                resolve: ctx => ctx.Projection.Id);
}

Available Extension Methods:

  • Resolve<TDbContext, TSource, TReturn, TProjection>() - Synchronous resolver with projection
  • ResolveAsync<TDbContext, TSource, TReturn, TProjection>() - Async resolver with projection
  • ResolveList<TDbContext, TSource, TReturn, TProjection>() - List resolver with projection
  • ResolveListAsync<TDbContext, TSource, TReturn, TProjection>() - Async list resolver with projection

The projection-based extension methods ensure required data is loaded by:

  1. Storing projection metadata in field metadata
  2. Compiling the projection expression for runtime execution
  3. Applying the projection to context.Source before calling the resolver
  4. Providing the projected data via ResolveProjectionContext<TDbContext, TProjection>

Problematic Pattern (Navigation Property May Be Null):

Field<int>("ParentId")
    .Resolve(context => context.Source.Parent.Id); // Parent may be null

Safe Pattern (Projection Ensures Data Is Loaded):

Field<int>("ParentId")
    .Resolve<IntegrationDbContext, ChildEntity, int, ParentEntity>(
        projection: x => x.Parent,
        resolve: ctx => ctx.Projection.Id); // Parent is guaranteed to be loaded

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.

Using WithProjection for Metadata-Only Fields

When a field has its own custom resolver but still needs to declare data requirements for the parent query's projection, use WithProjection. Unlike the Resolve<..., TProjection> methods which both set metadata and wrap the resolver, WithProjection only sets projection metadata — ensuring the parent query loads the required columns without altering how the field resolves.

This is useful for computed or permission-check fields that read from context.Source but aren't standard navigation or scalar fields.

// The parent query's SELECT projection will include Status,
// even if the client doesn't explicitly request a "status" field.
Field<NonNullGraphType<StringGraphType>, string>("statusLabel")
    .WithProjection(
        (Expression<Func<OrderEntity, OrderStatus>>)(_ => _.Status))
    .Resolve(context => context.Source.Status switch
    {
        OrderStatus.Active => "Active",
        OrderStatus.Cancelled => "Cancelled",
        _ => "Unknown"
    });

How it works:

  1. WithProjection stores the projection expression in the field's metadata (same key used by Resolve<..., TProjection>)
  2. When the parent entity query builds its SELECT projection, it analyzes all child field metadata — including this field
  3. Scalar properties referenced in the expression (e.g., Status) are added to the SELECT column list
  4. Navigation properties referenced in the expression trigger the appropriate Include calls

When to use WithProjection vs Resolve<..., TProjection>:

Scenario Use
Field resolver needs projected data passed in Resolve<..., TProjection>
Field has its own resolver but parent must load certain columns WithProjection
Permission/authorization fields that check entity state WithProjection
Fields resolved via reflection or external logic WithProjection

When Projections Are Not Used

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

  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

In these cases, the query falls back to loading the complete entity with all its properties.

Performance Benefits

Projections provide significant performance improvements:

  • Reduced database load - Only requested columns are retrieved from the database
  • Less data transferred - Smaller result sets from database to application
  • Lower memory usage - Smaller objects in memory

For queries that request only a few fields from entities with many properties, the performance improvement can be substantial.

Fields

Queries in GraphQL.net are defined using the Fields API. Fields can be mapped to Entity Framework by using IEfGraphQLService. IEfGraphQLService can be used in either a root query or a nested query via dependency injection. Alternatively convenience methods are exposed on the types EfObjectGraphType or EfObjectGraphType<TSource> for root or nested graphs respectively. The below samples all use the base type approach as it results in slightly less code.

Root Query

public class Query :
    QueryGraphType<MyDbContext>
{
    public Query(IEfGraphQLService<MyDbContext> graphQlService) :
        base(graphQlService)
    {
        AddSingleField(
            resolve: _ => _.DbContext.Companies,
            name: "company");
        AddQueryField(
            name: "companies",
            resolve: _ => _.DbContext.Companies);
    }
}

snippet source | anchor

AddQueryField will result in all matching being found and returned.

AddSingleField will result in a single matching being found and returned. This approach uses IQueryable<T>.SingleOrDefaultAsync as such, if no records are found a null will be returned, and if multiple records match then an exception will be thrown.

Typed Graph

public class CompanyGraph :
    EfObjectGraphType<MyDbContext,Company>
{
    public CompanyGraph(IEfGraphQLService<MyDbContext> graphQlService) :
        base(graphQlService)
    {
        AddNavigationListField(
            name: "employees",
            projection: _ => _.Employees,
            resolve: _ => _.Projection);
        AddNavigationConnectionField(
            name: "employeesConnection",
            projection: _ => _.Employees,
            resolve: _ => _.Projection);
        AutoMap();
    }
}

snippet source | anchor

Connections

Creating a page-able field is supported through GraphQL Connections 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.

Root Query

Graph Type

public class Query :
    QueryGraphType<MyDbContext>
{
    public Query(IEfGraphQLService<MyDbContext> graphQlService)
        :
        base(graphQlService) =>
        AddQueryConnectionField<Company>(
            name: "companies",
            resolve: _ => _.DbContext.Companies.OrderBy(_ => _.Name));
}

snippet source | anchor

Request

{
  companies(first: 2, after: "1") {
    totalCount
    edges {
      node {
        id
        content
        employees {
          id
          content
        }
      }
      cursor
    }
    pageInfo {
      startCursor
      endCursor
      hasPreviousPage
      hasNextPage
    }
  }
}

Response

{
  "data": {
    "companies": {
      "totalCount": 4,
      "edges": [
        {
          "node": {
            "id": "1",
            "content": "Company1",
            "employees": [
              {
                "id": "2",
                "content": "Employee1"
              },
              {
                "id": "3",
                "content": "Employee2"
              }
            ]
          },
          "cursor": "1"
        },
        {
          "node": {
            "id": "4",
            "content": "Company3",
            "employees": []
          },
          "cursor": "2"
        }
      ],
      "pageInfo": {
        "startCursor": "1",
        "endCursor": "2",
        "hasPreviousPage": true,
        "hasNextPage": true
      }
    }
  }
}

Typed Graph

public class CompanyGraph :
    EfObjectGraphType<MyDbContext, Company>
{
    public CompanyGraph(IEfGraphQLService<MyDbContext> graphQlService) :
        base(graphQlService) =>
        AddNavigationConnectionField(
            name: "employees",
            projection: _ => _.Employees,
            resolve: _ => _.Projection);
}

snippet source | anchor

Enums

public class DayOfTheWeekGraph : EnumerationGraphType<DayOfTheWeek>
{
}
public class ExampleGraph : ObjectGraphType<Example>
{
    public ExampleGraph()
    {
        Field(x => x.DayOfTheWeek, type: typeof(DayOfTheWeekGraph));
    }
}

AutoMap

Mapper.AutoMap can be used to remove repetitive code by mapping all properties of a type.

For example for this graph:

public class EmployeeGraph :
    EfObjectGraphType<SampleDbContext, Employee>
{
    public EmployeeGraph(IEfGraphQLService<SampleDbContext> graphQlService) :
        base(graphQlService)
    {
        AddNavigationField(
            name: "company",
            resolve: context => context.Source.Company);
        Field(employee => employee.Age);
        Field(employee => employee.Content);
        Field(employee => employee.CompanyId);
        Field(employee => employee.Id);
    }
}

The equivalent graph using AutoMap is:

public class EmployeeGraph :
    EfObjectGraphType<SampleDbContext, Employee>
{
    public EmployeeGraph(IEfGraphQLService<SampleDbContext> graphQlService) :
        base(graphQlService)
    {
        AutoMap();
    }
}

The underlying behavior of AutoMap is:

  • Calls IEfGraphQLService{TDbContext}.AddNavigationField{TSource,TReturn} for all non-list EF navigation properties.
  • Calls IEfGraphQLService{TDbContext}.AddNavigationListField{TSource,TReturn} for all EF navigation properties.
  • Calls ComplexGraphType{TSourceType}.AddField for all other properties

An optional list of exclusions can be passed to exclude a subset of properties from mapping.

Mapper.AddIgnoredType can be used to exclude properties (of a certain type) from mapping.

Manually Apply WhereExpression

In some cases, it may be necessary to use Field instead of AddQueryField/AddSingleField/etc but still would like to use apply the where argument. This can be useful when the returned Graph type is not for an entity (for example, aggregate results). To support this:

  • Add the WhereExpressionGraph argument
  • Apply the where argument expression using ExpressionBuilder<T>.BuildPredicate(whereExpression)

Field<ListGraphType<EmployeeSummaryGraphType>>("employeeSummary")
    .Argument<ListGraphType<WhereExpressionGraph>>("where")
    .Resolve(context =>
    {
        var dbContext = ResolveDbContext(context);
        IQueryable<Employee> query = dbContext.Employees;

        if (context.HasArgument("where"))
        {
            var wheres = context.GetArgument<List<WhereExpression>>("where");

            var predicate = ExpressionBuilder<Employee>.BuildPredicate(wheres);
            query = query.Where(predicate);
        }

        return query
            .GroupBy(_ => _.CompanyId)
            .Select(_ => new EmployeeSummary
            {
                CompanyId = _.Key,
                AverageAge = _.Average(_ => _.Age),
            });
    });

snippet source | anchor

Resolving DbContext

Sometimes it is necessary to access the current DbContext from withing the base QueryGraphType.Field method. in this case the custom ResolveEfFieldContext is not available. In this scenario QueryGraphType.ResolveDbContext can be used to resolve the current DbContext.

public class Query :
    QueryGraphType<MyDbContext>
{
    public Query(IEfGraphQLService<MyDbContext> graphQlService) :
        base(graphQlService) =>
        Field<ListGraphType<CompanyGraph>>("oldCompanies")
            .Resolve(context =>
            {
                // uses the base QueryGraphType to resolve the db context
                var data = ResolveDbContext(context);
                return data.Companies.Where(_ => _.Age > 10);
            });
}

snippet source | anchor

ArgumentProcessor

ArgumentProcessor (via the method ApplyGraphQlArguments) is responsible for extracting the various parts of the GraphQL query argument and applying them to an IQueryable<T>. So, for example, each where argument is mapped to a IQueryable.Where and each skip argument is mapped to a IQueryable.Where.

The arguments are parsed and mapped each time a query is executer.

ArgumentProcessor is generally considered an internal API and not for public use. However there are some advanced scenarios, for example when building subscriptions, that ArgumentProcessor is useful.