- Container Registration
- DocumentExecuter
- Connection Types
- DependencyInjection and ASP.Net Core
- Multiple DbContexts
- Testing the GraphQlController
- GraphQlExtensions
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)
EfGraphQLConventions.RegisterInContainer<MyDbContext>(
serviceCollection,
model: ModelBuilder.GetInstance());
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;
}
}
A delegate that resolves the DbContext.
using Microsoft.EntityFrameworkCore;
namespace GraphQL.EntityFramework
{
public delegate TDbContext ResolveDbContext<out TDbContext>(object userContext)
where TDbContext : DbContext;
}
It has access to the current GraphQL user context.
If null then the DbContext will be resolved from the container.
A delegate that resolves the Filters.
namespace GraphQL.EntityFramework
{
public delegate Filters? ResolveFilters(object userContext);
}
It has access to the current GraphQL user context.
If null then the Filters will be resolved from the container.
public static void RegisterInContainer<TDbContext>(
IServiceCollection services,
ResolveDbContext<TDbContext>? resolveDbContext = null,
IModel? model = null,
ResolveFilters? resolveFilters = null)
EfGraphQLConventions.RegisterInContainer<MyDbContext>(
serviceCollection,
model: ModelBuilder.GetInstance());
Then the IEfGraphQLService
can be resolved via dependency injection in GraphQL.net to be used in ObjectGraphType
s when adding query fields.
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 GraphQL.Execution;
using GraphQL.Language.AST;
namespace GraphQL.EntityFramework
{
public class EfDocumentExecuter :
DocumentExecuter
{
protected override IExecutionStrategy SelectExecutionStrategy(ExecutionContext context)
{
Guard.AgainstNull(nameof(context), context);
if (context.Operation.OperationType == OperationType.Query)
{
return new SerialExecutionStrategy();
}
return base.SelectExecutionStrategy(context);
}
}
}
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)
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;
public GraphQlController(ISchema schema, IDocumentExecuter executer)
{
this.schema = schema;
this.executer = executer;
}
[HttpPost]
public Task<ExecutionResult> Post(
[BindRequired, FromBody] PostBody body,
CancellationToken cancellation)
{
return Execute(body.Query, body.OperationName, body.Variables, cancellation);
}
public class PostBody
{
public string? OperationName;
public string Query = null!;
public JObject? Variables;
}
[HttpGet]
public Task<ExecutionResult> Get(
[FromQuery] string query,
[FromQuery] string? variables,
[FromQuery] string? operationName,
CancellationToken cancellation)
{
var jObject = ParseVariables(variables);
return Execute(query, operationName, jObject, cancellation);
}
Task<ExecutionResult> Execute(string query,
string? operationName,
JObject? variables,
CancellationToken cancellation)
{
var options = new ExecutionOptions
{
Schema = schema,
Query = query,
OperationName = operationName,
Inputs = variables?.ToInputs(),
CancellationToken = cancellation,
#if (DEBUG)
ExposeExceptions = true,
EnableMetrics = true,
#endif
};
return executer.ExecuteAsync(options);
}
static JObject? ParseVariables(string? variables)
{
if (variables == null)
{
return null;
}
try
{
return JObject.Parse(variables);
}
catch (Exception exception)
{
throw new Exception("Could not parse variables.", exception);
}
}
}
Multiple different DbContext types can be registered and used.
A user context that exposes both types.
public class UserContext
{
public UserContext(DbContext1 context1, DbContext2 context2)
{
DbContext1 = context1;
DbContext2 = context2;
}
public readonly DbContext1 DbContext1;
public readonly DbContext2 DbContext2;
}
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);
Use the user type to pass in both DbContext instances.
var executionOptions = new ExecutionOptions
{
Schema = schema,
Query = query,
UserContext = new UserContext(dbContext1, dbContext2)
};
Use both DbContexts in a Query:
using GraphQL.EntityFramework;
using GraphQL.Types;
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;
});
}
}
Use a DbContext in a Graph:
using GraphQL.EntityFramework;
public class Entity1Graph :
EfObjectGraphType<DbContext1, Entity1>
{
public Entity1Graph(IEfGraphQLService<DbContext1> graphQlService) :
base(graphQlService)
{
Field(x => x.Id);
Field(x => x.Property);
}
}
The GraphQlController
can be tested using the ASP.NET Integration tests via the Microsoft.AspNetCore.Mvc.Testing NuGet package.
public class GraphQlControllerTests :
VerifyBase
{
static HttpClient client = null!;
static WebSocketClient websocketClient = null!;
static GraphQlControllerTests()
{
var server = GetTestServer();
client = server.CreateClient();
websocketClient = server.CreateWebSocketClient();
websocketClient.ConfigureRequest =
request =>
{
var headers = request.Headers;
headers["Sec-WebSocket-Protocol"] = "graphql-ws";
};
}
[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 Get_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 Get_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 Get_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 Get_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 Get_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 Get_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();
Assert.Contains("{\"employees\":[{\"id\":3},{\"id\":5}]}", result);
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task Post()
{
var query = @"
{
companies
{
id
}
}";
using var response = await ClientQueryExecutor.ExecutePost(client, query);
var result = await response.Content.ReadAsStringAsync();
Assert.Contains(
"{\"companies\":[{\"id\":1},{\"id\":4},{\"id\":6},{\"id\":7}]}",
result);
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task Post_variable()
{
var query = @"
query ($id: ID!)
{
companies(ids:[$id])
{
id
}
}";
var variables = new
{
id = "1"
};
using var response = await ClientQueryExecutor.ExecutePost(client, query, variables);
var result = await response.Content.ReadAsStringAsync();
Assert.Contains("{\"companies\":[{\"id\":1}]}", result);
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task Should_subscribe_to_companies()
{
var resetEvent = new AutoResetEvent(false);
var result = new GraphQLHttpSubscriptionResult(
new Uri("http://example.com/graphql"),
new GraphQLRequest
{
Query = @"
subscription
{
companyChanged
{
id
}
}"
},
websocketClient,response => {
if (response == null)
{
return;
}
Assert.Null(response.Errors);
if (response.Data != null)
{
resetEvent.Set();
}});
var cancellationSource = new CancellationTokenSource();
var task = result.StartAsync(cancellationSource.Token);
Assert.True(resetEvent.WaitOne(TimeSpan.FromSeconds(10)));
cancellationSource.Cancel();
await task;
}
static TestServer GetTestServer()
{
var hostBuilder = new WebHostBuilder();
hostBuilder.UseStartup<Startup>();
return new TestServer(hostBuilder);
}
public GraphQlControllerTests(ITestOutputHelper output) :
base(output)
{
}
}
The GraphQlExtensions
class exposes some helper methods:
Wraps the DocumentExecuter.ExecuteAsync
to throw if there are any errors.
public static async Task<ExecutionResult> ExecuteWithErrorCheck(
this IDocumentExecuter executer,
ExecutionOptions options)
{
Guard.AgainstNull(nameof(executer), executer);
Guard.AgainstNull(nameof(options), options);
var executionResult = await executer.ExecuteAsync(options);
var errors = executionResult.Errors;
if (errors != null && errors.Count > 0)
{
if (errors.Count == 1)
{
throw errors.First();
}
throw new AggregateException(errors);
}
return executionResult;
}