Skip to content

Latest commit

 

History

History
613 lines (475 loc) · 23.9 KB

File metadata and controls

613 lines (475 loc) · 23.9 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 resolve methods:

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

Available Methods:

Inside an EfObjectGraphType or EfInterfaceGraphType, the Field methods return an EfFieldBuilder<TDbContext, TSource, TReturn> that already knows the IEfGraphQLService<TDbContext>. It adds:

  • Resolve(projection, resolve) - Synchronous resolver with projection
  • ResolveAsync(projection, resolve) - Async resolver with projection

Only TProjection is inferred, from the projection expression; the other type arguments come from the graph type. The builder's fluent methods (Description, Argument, Configure, etc.) are overridden to keep returning EfFieldBuilder, so the projection-based methods remain available anywhere in the chain. Extension methods from other libraries return the base FieldBuilder, so call those after the projection-based resolve.

For a plain ObjectGraphType, or for the list variants, the equivalent extension methods on FieldBuilder take the service as their first argument: Resolve(graphQlService, projection, resolve), ResolveAsync(...), ResolveList(...) and ResolveListAsync(...). The service is used at execution time to resolve the DbContext and filters.

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(
        projection: _ => _.Parent,
        resolve: _ => _.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(_ => _.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.

Paging

Cursors are the zero based index of the edge in the ordered set. after and before are exclusive, and bound the window of items a page is taken from. first keeps that many from the start of the window, then last keeps that many from the end, in the order the Relay specification applies them. Edges are always in the set's order, whichever end the page was taken from. hasPreviousPage is true when the page starts after the first item, hasNextPage when items exist after the page. An empty page has null startCursor and endCursor.

A root connection reads its items with one query and the total with another. The count query only runs when the selection needs it: when totalCount is selected, or when paging with last or before, which place the window from the end. Paged with first and after, a selection of edges, items and pageInfo is a single query: it reads one row past the page, and that row answers hasNextPage. Without the count, an empty page past the end reports hasPreviousPage false, which the Relay specification allows when paging forward.

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 a WhereGraph<T> argument, where T is the type being filtered. The generated input type is named after T, so WhereGraph<Employee> is EmployeeWhere in the schema.
  • Read the argument as a WhereExpression and apply it using ExpressionBuilder<T>.BuildPredicate(whereExpression)

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

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

            var predicate = ExpressionBuilder<Employee>.BuildPredicate(where);
            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.