Skip to content

Latest commit

 

History

History
750 lines (614 loc) · 25.2 KB

configuration.md

File metadata and controls

750 lines (614 loc) · 25.2 KB

Configuration

Container Registration

Enabling is done via registering in a container.

The container registration can be done via adding to a IServiceCollection:

public static void RegisterInContainer<TDbContext>(
        IServiceCollection services,
        ResolveDbContext<TDbContext>? resolveDbContext = null,
        IModel? model = null,
        ResolveFilters? resolveFilters = null,
        bool disableTracking = false,
        bool disableAsync = false)

snippet source | anchor

EfGraphQLConventions.RegisterInContainer<MyDbContext>(
    serviceCollection,
    model: ModelBuilder.GetInstance());

snippet source | anchor

Inputs

IModel

Configuration requires an instance of Microsoft.EntityFrameworkCore.Metadata.IModel. This can be passed in as a parameter, or left as null to be resolved from the container. When IModel is resolved from the container, IServiceProvider.GetService is called first on IModel, then on TDbContext. If both return null, then an exception will be thrown.

To build an instance of an IModel at configuration time it can be helpful to have a class specifically for that purpose:

static class ModelBuilder
{
    public static IModel GetInstance()
    {
        var builder = new DbContextOptionsBuilder();
        builder.UseSqlServer("Fake");
        using var context = new MyDbContext(builder.Options);
        return context.Model;
    }
}

snippet source | anchor

Resolve DbContext

A delegate that resolves the DbContext.

namespace GraphQL.EntityFramework;

public delegate TDbContext ResolveDbContext<out TDbContext>(object userContext)
    where TDbContext : DbContext;

snippet source | anchor

It has access to the current GraphQL user context.

If null then the DbContext will be resolved from the container.

Resolve Filters

A delegate that resolves the Filters.

namespace GraphQL.EntityFramework;

public delegate Filters? ResolveFilters(object userContext);

snippet source | anchor

It has access to the current GraphQL user context.

If null then the Filters will be resolved from the container.

DisableTracking

Setting disableTracking to true results in the use of EntityFrameworkQueryableExtensions.AsNoTracking<TEntity> for all IQueryable<T> operations. This can result in better performance since EF does not need to track entities when querying. Not that AsNoTracking does no support results in a cycle, which will result in the following error:

The Include path 'DataItems->Section' results in a cycle.
Cycles are not allowed in no-tracking queries; either use a tracking query or remove the cycle.

Usage

public static void RegisterInContainer<TDbContext>(
        IServiceCollection services,
        ResolveDbContext<TDbContext>? resolveDbContext = null,
        IModel? model = null,
        ResolveFilters? resolveFilters = null,
        bool disableTracking = false,
        bool disableAsync = false)

snippet source | anchor

EfGraphQLConventions.RegisterInContainer<MyDbContext>(
    serviceCollection,
    model: ModelBuilder.GetInstance());

snippet source | anchor

Then the IEfGraphQLService can be resolved via dependency injection in GraphQL.net to be used in ObjectGraphTypes when adding query fields.

DocumentExecuter

The default GraphQL DocumentExecuter uses Task.WhenAll to resolve async fields. This can result in multiple EF queries being executed on different threads and being resolved out of order. In this scenario the following exception will be thrown.

Message: System.InvalidOperationException : A second operation started on this context before a previous operation completed. This is usually caused by different threads using the same instance of DbContext, however instance members are not guaranteed to be thread safe. This could also be caused by a nested query being evaluated on the client, if this is the case rewrite the query avoiding nested invocations.

To avoid this a custom implementation of DocumentExecuter but be used that uses SerialExecutionStrategy when the operation type is OperationType.Query. There is one included in this library named EfDocumentExecuter:

using ExecutionContext = GraphQL.Execution.ExecutionContext;

namespace GraphQL.EntityFramework;

public class EfDocumentExecuter :
    DocumentExecuter
{
    protected override IExecutionStrategy SelectExecutionStrategy(ExecutionContext context)
    {
        if (context.Operation.Operation == OperationType.Query)
        {
            return new SerialExecutionStrategy();
        }

        return base.SelectExecutionStrategy(context);
    }
}

snippet source | anchor

Connection Types

GraphQL enables paging via Connections. When using Connections in GraphQL.net it is necessary to register several types in the container:

services.AddTransient(typeof(ConnectionType<>));
services.AddTransient(typeof(EdgeType<>));
services.AddSingleton<PageInfoType>();

There is a helper methods to perform the above:

EfGraphQLConventions.RegisterConnectionTypesInContainer(IServiceCollection services);

or

EfGraphQLConventions.RegisterConnectionTypesInContainer(Action<Type> register)

DependencyInjection and ASP.Net Core

As with GraphQL .net, GraphQL.EntityFramework makes no assumptions on the container or web framework it is hosted in. However given Microsoft.Extensions.DependencyInjection and ASP.Net Core are the most likely usage scenarios, the below will address those scenarios explicitly.

See the GraphQL .net documentation for ASP.Net Core and the ASP.Net Core sample.

The Entity Framework Data Context instance is generally scoped per request. This can be done in the Startup.ConfigureServices method:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddScoped(
          provider => MyDbContextBuilder.BuildDbContext());
    }
}

Entity Framework also provides several helper methods to control a DbContexts lifecycle. For example:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddDbContext<MyDbContext>(
          provider => DbContextBuilder.BuildDbContext());
    }
}

See also EntityFrameworkServiceCollectionExtensions

With the DbContext existing in the container, it can be resolved in the controller that handles the GraphQL query:

[Route("[controller]")]
[ApiController]
public class GraphQlController :
    Controller
{
    IDocumentExecuter executer;
    ISchema schema;
    static GraphQLSerializer writer = new(true);

    public GraphQlController(ISchema schema, IDocumentExecuter executer)
    {
        this.schema = schema;
        this.executer = executer;
    }

    [HttpGet]
    public Task Get(
        [FromQuery] string query,
        [FromQuery] string? variables,
        [FromQuery] string? operationName,
        CancellationToken cancellation)
    {
        var inputs = variables.ToInputs();
        return Execute(query, operationName, inputs, cancellation);
    }

    public class GraphQLQuery
    {
        public string? OperationName { get; set; }
        public string Query { get; set; } = null!;
        public string? Variables { get; set; }
    }

    [HttpPost]
    public Task Post(
        [FromBody]GraphQLQuery query,
        CancellationToken cancellation)
    {
        var inputs = query.Variables.ToInputs();
        return Execute(query.Query, query.OperationName, inputs, cancellation);
    }

    async Task Execute(string query,
        string? operationName,
        Inputs? variables,
        CancellationToken cancellation)
    {
        var options = new ExecutionOptions
        {
            Schema = schema,
            Query = query,
            OperationName = operationName,
            Variables = variables,
            CancellationToken = cancellation,
#if (DEBUG)
            ThrowOnUnhandledException = true,
            EnableMetrics = true,
#endif
        };
        var executeAsync = await executer.ExecuteAsync(options);

        await writer.WriteAsync(Response.Body, executeAsync, cancellation);
    }
}

snippet source | anchor

Multiple DbContexts

Multiple different DbContext types can be registered and used.

UserContext

A user context that exposes both types.

public class UserContext: Dictionary<string, object?>
{
    public UserContext(DbContext1 context1, DbContext2 context2)
    {
        DbContext1 = context1;
        DbContext2 = context2;
    }

    public readonly DbContext1 DbContext1;
    public readonly DbContext2 DbContext2;
}

snippet source | anchor

Register in container

Register both DbContext types in the container and include how those instance can be extracted from the GraphQL context:

EfGraphQLConventions.RegisterInContainer(
    services,
    userContext => ((UserContext) userContext).DbContext1);
EfGraphQLConventions.RegisterInContainer(
    services,
    userContext => ((UserContext) userContext).DbContext2);

snippet source | anchor

ExecutionOptions

Use the user type to pass in both DbContext instances.

var executionOptions = new ExecutionOptions
{
    Schema = schema,
    Query = query,
    UserContext = new UserContext(dbContext1, dbContext2)
};

snippet source | anchor

Query

Use both DbContexts in a Query:

public class MultiContextQuery :
    ObjectGraphType
{
    public MultiContextQuery(
        IEfGraphQLService<DbContext1> efGraphQlService1,
        IEfGraphQLService<DbContext2> efGraphQlService2)
    {
        efGraphQlService1.AddSingleField(
            graph: this,
            name: "entity1",
            resolve: context =>
            {
                var userContext = (UserContext) context.UserContext;
                return userContext.DbContext1.Entities;
            });
        efGraphQlService2.AddSingleField(
            graph: this,
            name: "entity2",
            resolve: context =>
            {
                var userContext = (UserContext) context.UserContext;
                return userContext.DbContext2.Entities;
            });
    }
}

snippet source | anchor

GraphType

Use a DbContext in a Graph:

public class Entity1GraphType :
    EfObjectGraphType<DbContext1, Entity1>
{
    public Entity1GraphType(IEfGraphQLService<DbContext1> graphQlService) :
        base(graphQlService) =>
        AutoMap();
}

snippet source | anchor

Testing the GraphQlController

The GraphQlController can be tested using the ASP.NET Integration tests via the Microsoft.AspNetCore.Mvc.Testing NuGet package.

[UsesVerify]
public class GraphQlControllerTests
{
    static HttpClient client;
    static ClientQueryExecutor clientQueryExecutor;
    static WebSocketClient webSocket;

    static GraphQlControllerTests()
    {
        var server = GetTestServer();
        client = server.CreateClient();
        webSocket = server.CreateWebSocketClient();
        webSocket.ConfigureRequest =
            request =>
            {
                var headers = request.Headers;
                headers["Sec-WebSocket-Protocol"] = "graphql-ws";
            };
        clientQueryExecutor = new(JsonConvert.SerializeObject);
    }

    [Fact]
    public async Task Get()
    {
        var query = """
            {
              companies
              {
                id
              }
            }
            """;
        using var response = await clientQueryExecutor.ExecuteGet(client, query);
        response.EnsureSuccessStatusCode();
        await Verify(await response.Content.ReadAsStringAsync());
    }

    [Fact]
    public async Task Post()
    {
        var query = """
            {
              companies
              {
                id
              }
            }
            """;
        using var response = await clientQueryExecutor.ExecutePost(client, query);
        response.EnsureSuccessStatusCode();
        await Verify(await response.Content.ReadAsStringAsync());
    }

    [Fact]
    public async Task Single()
    {
        var query = """
            query ($id: ID!)
            {
              company(id:$id)
              {
                id
              }
            }
            """;
        var variables = new
        {
            id = "1"
        };

        using var response = await clientQueryExecutor.ExecuteGet(client, query, variables);
        response.EnsureSuccessStatusCode();
        await Verify(await response.Content.ReadAsStringAsync());
    }

    [Fact]
    public async Task Single_not_found()
    {
        var query = """
            query ($id: ID!)
            {
              company(id:$id)
              {
                id
              }
            }
            """;
        var variables = new
        {
            id = "99"
        };

        using var response = await clientQueryExecutor.ExecuteGet(client, query, variables);
        var result = await response.Content.ReadAsStringAsync();
        Assert.Contains("Not found", result);
    }

    [Fact]
    public async Task Variable()
    {
        var query = """
            query ($id: ID!)
            {
              companies(ids:[$id])
              {
                id
              }
            }
            """;
        var variables = new
        {
            id = "1"
        };

        using var response = await clientQueryExecutor.ExecuteGet(client, query, variables);
        response.EnsureSuccessStatusCode();
        await Verify(await response.Content.ReadAsStringAsync());
    }

    [Fact]
    public async Task Companies_paging()
    {
        var after = 1;
        var query = $$"""
            query {
              companiesConnection(first:2, after:"{{after}}") {
                edges {
                  cursor
                  node {
                    id
                  }
                }
                pageInfo {
                  endCursor
                  hasNextPage
                }
              }
            }
            """;
        using var response = await clientQueryExecutor.ExecuteGet(client, query);
        response.EnsureSuccessStatusCode();
        await Verify(await response.Content.ReadAsStringAsync());
    }

    [Fact]
    public async Task Employee_summary()
    {
        var query = """
            query {
              employeeSummary {
                companyId
                averageAge
              }
            }
            """;
        using var response = await clientQueryExecutor.ExecuteGet(client, query);
        response.EnsureSuccessStatusCode();
        await Verify(await response.Content.ReadAsStringAsync());
    }

    [Fact]
    public async Task Complex_query_result()
    {
        var query = """
            query {
              employees (
                where: [
                  {groupedExpressions: [
                    {path: "content", comparison: contains, value: "4", connector: or},
            
                      { path: "content", comparison: contains, value: "2"}
                  ], connector: and},
                  {path: "age", comparison: greaterThanOrEqual, value: "31"}
                ]
              ) {
                id
              }
            }
            """;
        using var response = await clientQueryExecutor.ExecuteGet(client, query);
        var result = await response.Content.ReadAsStringAsync();
        response.EnsureSuccessStatusCode();
        await Verify(result);
    }

    static TestServer GetTestServer()
    {
        var builder = new WebHostBuilder();
        builder.UseStartup<Startup>();
        return new(builder);
    }
}

snippet source | anchor

GraphQlExtensions

The GraphQlExtensions class exposes some helper methods:

ExecuteWithErrorCheck

Wraps the DocumentExecuter.ExecuteAsync to throw if there are any errors.

public static async Task<ExecutionResult> ExecuteWithErrorCheck(
    this IDocumentExecuter executer,
    ExecutionOptions options)
{
    var executionResult = await executer.ExecuteAsync(options);

    options.ThrowOnUnhandledException = true;
    var errors = executionResult.Errors;
    if (errors is { Count: > 0 })
    {
        if (errors.Count == 1)
        {
            throw errors.First();
        }

        throw new AggregateException(errors);
    }

    return executionResult;
}

snippet source | anchor

EF Core TPH and GraphQL Interface

Map a table-per-hierarchy (TPH) EF Core pattern to a GraphQL interface to describe the shared properties in the base type, and then each type in the hierarchy to its own GraphQL type. From now on, a GraphQL query returning the interface type could be defined, allowing clients to request either common properties or specific one using inline fragments.

EF Core Entities

public abstract class InheritedEntity
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string? Property { get; set; }
    public IList<DerivedChildEntity> ChildrenFromBase { get; set; } = new List<DerivedChildEntity>();
}

snippet source | anchor

public class DerivedEntity : InheritedEntity
{
}

snippet source | anchor

GraphQL types

public class InterfaceGraphType :
    EfInterfaceGraphType<IntegrationDbContext, InheritedEntity>
{
    public InterfaceGraphType(IEfGraphQLService<IntegrationDbContext> graphQlService) :
        base(graphQlService)
    {
        Field(e => e.Id);
        Field(e => e.Property, nullable: true);
        AddNavigationConnectionField<DerivedChildEntity>(
            name: "childrenFromInterface",
            includeNames: new[] { "ChildrenFromBase" });
    }
}

snippet source | anchor

public class DerivedGraphType :
    EfObjectGraphType<IntegrationDbContext, DerivedEntity>
{
    public DerivedGraphType(IEfGraphQLService<IntegrationDbContext> graphQlService) :
        base(graphQlService)
    {
        AddNavigationConnectionField(
            name: "childrenFromInterface",
            e => e.Source.ChildrenFromBase);
        AutoMap();
        Interface<InterfaceGraphType>();
        IsTypeOf = obj => obj is DerivedEntity;
    }
}

snippet source | anchor

GraphQL query

efGraphQlService.AddQueryConnectionField(
    this,
    itemGraphType: typeof(InterfaceGraph),
    name: "interfaceGraphConnection",
    resolve: context => context.DbContext.InheritedEntities);