Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/Benchmarks/Benchmarks.csproj
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Signed so the library can expose its internals to the stage benchmarks -->
<SignAssembly>true</SignAssembly>
<AssemblyOriginatorKeyFile>..\key.snk</AssemblyOriginatorKeyFile>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="BenchmarkDotNet" />
<PackageReference Include="EfLocalDb" />
<PackageReference Include="GraphQL" />
<PackageReference Include="GraphQL.SystemTextJson" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
</ItemGroup>

<ItemGroup>
Expand Down
271 changes: 271 additions & 0 deletions src/Benchmarks/RequestSplitBenchmark.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
namespace Benchmarks;

/// <summary>
/// Splits a request into its stages so the share of the library can be read off: GraphQL.NET
/// execution alone for the same result shape, the full request through the library, and the
/// stages the library adds (argument trees, the projection tree, the EF execution of that tree).
/// The residual, Full less GraphQLNetOnly less ApplyProjection less EfExecute, is the per row
/// work in the resolvers.
/// </summary>
[MemoryDiagnoser]
[InProcess]
[WarmupCount(3)]
[IterationCount(10)]
public class RequestSplitBenchmark
{
const string simpleQuery = """
{
parents {
id
property
children {
id
property
}
}
}
""";

const string argumentsQuery = """
{
parents(where: {property: {startsWith: "Parent 1"}}, orderBy: {property: ascending}) {
id
property
children(orderBy: {property: descending}) {
id
property
}
}
}
""";

const string fragmentsQuery = """
{
parents {
...parentFields
children {
... on Child {
id
property
}
}
}
}
fragment parentFields on Parent {
id
property
}
""";

IDocumentExecuter executer = null!;
BenchmarkDbContext database = null!;
IServiceProvider provider = null!;
ISchema librarySchema = null!;
ISchema plainSchema = null!;
IncludeAppender includeAppender = null!;
IResolveFieldContext capturedSimple = null!;
IResolveFieldContext capturedArguments = null!;
IQueryable<ParentEntity> projected = null!;
List<string> keyNames = ["Id"];

[GlobalSetup]
public async Task Setup()
{
var builder = new DbContextOptionsBuilder<BenchmarkDbContext>();
// GlobalSetup runs once per benchmark method, and a named in memory database outlives the
// context, so a fixed name would seed on top of the previous method's data
builder.UseInMemoryDatabase($"RequestSplit{Guid.NewGuid():N}");
database = new(builder.Options);
await Seed.Run(database);

var services = new ServiceCollection();
services.AddSingleton(database);
services.AddSingleton<ParentGraphType>();
services.AddSingleton<ChildGraphType>();
services.AddSingleton<CapturingQuery>();
EfGraphQLConventions.RegisterInContainer<BenchmarkDbContext>(
services,
(_, _) => database,
database.Model,
disableTracking: true);
provider = services.BuildServiceProvider();
librarySchema = new CapturingSchema(provider, provider.GetRequiredService<CapturingQuery>());
executer = new EfDocumentExecuter();

var plainParents = await database.Parents
.Include(_ => _.Children)
.AsNoTracking()
.ToListAsync();
plainSchema = new PlainSchema(plainParents);

var model = database.Model;
includeAppender = new(
NavigationReader.GetNavigationProperties(model),
model.GetKeyNames(),
ForeignKeyExtractor.GetForeignKeyProperties(model),
new Dictionary<Type, IReadOnlyList<Type>>());

// One execution per shape, to capture the field context the stages replay
await Execute(librarySchema, simpleQuery);
capturedSimple = CapturingQuery.Captured!;
await Execute(librarySchema, argumentsQuery);
capturedArguments = CapturingQuery.Captured!;
await Execute(librarySchema, fragmentsQuery);
await Execute(plainSchema, simpleQuery);

projected = includeAppender.ApplyProjection<BenchmarkDbContext, ParentEntity>(capturedSimple, null, Parents);
}

IQueryable<ParentEntity> Parents => database.Parents.AsNoTracking();

async Task<int> Execute(ISchema schema, string query)
{
var result = await executer.ExecuteAsync(options =>
{
options.Schema = schema;
options.Query = query;
options.RequestServices = provider;
});

if (result.Errors is { Count: > 0 })
{
throw new(string.Join(Environment.NewLine, result.Errors.Select(_ => _.Message)));
}

var data = (IDictionary<string, object?>) ((GraphQL.Execution.ExecutionNode) result.Data!).ToValue()!;
var parents = (IEnumerable<object>) data["parents"]!;
return parents.Count();
}

[Benchmark]
public Task<int> GraphQLNetOnly() =>
Execute(plainSchema, simpleQuery);

[Benchmark(Baseline = true)]
public Task<int> Full() =>
Execute(librarySchema, simpleQuery);

[Benchmark]
public Task<int> FullWithArguments() =>
Execute(librarySchema, argumentsQuery);

[Benchmark]
public Task<int> FullWithFragments() =>
Execute(librarySchema, fragmentsQuery);

[Benchmark]
public System.Linq.Expressions.Expression ApplyArguments() =>
Parents.ApplyGraphQlArguments(capturedArguments, keyNames, true, false).Expression;

[Benchmark]
public System.Linq.Expressions.Expression ApplyProjection() =>
includeAppender.ApplyProjection<BenchmarkDbContext, ParentEntity>(capturedSimple, null, Parents).Expression;

[Benchmark]
public System.Linq.Expressions.Expression ApplyProjectionWithArguments()
{
var query = Parents.ApplyGraphQlArguments(capturedArguments, keyNames, true, false);
return includeAppender.ApplyProjection<BenchmarkDbContext, ParentEntity>(capturedArguments, null, query).Expression;
}

[Benchmark]
public Task<List<ParentEntity>> EfExecute() =>
projected.ToListAsync();

[GlobalCleanup]
public void Cleanup()
{
database.Dispose();
(provider as IDisposable)?.Dispose();
}
}

public class CapturingQuery :
QueryGraphType<BenchmarkDbContext>
{
public static ResolveEfFieldContext<BenchmarkDbContext, object>? Captured;

public CapturingQuery(IEfGraphQLService<BenchmarkDbContext> efGraphQlService) :
base(efGraphQlService) =>
AddQueryField(
name: "parents",
resolve: _ =>
{
Captured = _;
return _.DbContext.Parents;
});
}

public class CapturingSchema :
Schema
{
public CapturingSchema(IServiceProvider resolver, CapturingQuery query) :
base(resolver)
{
RegisterTypeMapping(typeof(ParentEntity), typeof(ParentGraphType));
RegisterTypeMapping(typeof(ChildEntity), typeof(ChildGraphType));
Query = query;
}
}

public class PlainParentGraph :
ObjectGraphType<ParentEntity>
{
public PlainParentGraph()
{
Name = "Parent";
Field(_ => _.Id);
Field(_ => _.Property, nullable: true);
Field<NonNullGraphType<ListGraphType<NonNullGraphType<PlainChildGraph>>>>("children")
.Resolve(_ => _.Source.Children);
}
}

public class PlainChildGraph :
ObjectGraphType<ChildEntity>
{
public PlainChildGraph()
{
Name = "Child";
Field(_ => _.Id);
Field(_ => _.Property, nullable: true);
}
}

public class PlainQuery :
ObjectGraphType
{
public PlainQuery(List<ParentEntity> parents) =>
Field<NonNullGraphType<ListGraphType<NonNullGraphType<PlainParentGraph>>>>("parents")
.Resolve(_ => parents);
}

public class PlainSchema :
Schema
{
public PlainSchema(List<ParentEntity> parents)
{
RegisterTypeMapping(typeof(ParentEntity), typeof(PlainParentGraph));
RegisterTypeMapping(typeof(ChildEntity), typeof(PlainChildGraph));
Query = new PlainQuery(parents);
}
}

public static class Seed
{
public static async Task Run(BenchmarkDbContext database)
{
for (var i = 0; i < 100; i++)
{
var parent = new ParentEntity { Property = $"Parent {i}" };
for (var j = 0; j < 5; j++)
{
parent.Children.Add(new() { Property = $"Child {i}-{j}", Parent = parent });
}

database.Parents.Add(parent);
}

await database.SaveChangesAsync();
}
}
98 changes: 98 additions & 0 deletions src/Benchmarks/RequestSplitSqlServerBenchmark.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using EfLocalDb;

namespace Benchmarks;

/// <summary>
/// The full request and the EF execution alone against SQL Server LocalDB, to put the share of
/// the library next to a real database round trip.
/// </summary>
[MemoryDiagnoser]
[InProcess]
[WarmupCount(3)]
[IterationCount(10)]
public class RequestSplitSqlServerBenchmark
{
const string simpleQuery = """
{
parents {
id
property
children {
id
property
}
}
}
""";

static SqlInstance<BenchmarkDbContext> sqlInstance = new(builder => new(builder.Options));

SqlDatabase<BenchmarkDbContext> database = null!;
IDocumentExecuter executer = null!;
IServiceProvider provider = null!;
ISchema schema = null!;
IQueryable<ParentEntity> projected = null!;

[GlobalSetup]
public async Task Setup()
{
database = await sqlInstance.Build();
var context = database.Context;
await Seed.Run(context);

var services = new ServiceCollection();
services.AddSingleton(context);
services.AddSingleton<ParentGraphType>();
services.AddSingleton<ChildGraphType>();
services.AddSingleton<CapturingQuery>();
EfGraphQLConventions.RegisterInContainer<BenchmarkDbContext>(
services,
(_, _) => context,
sqlInstance.Model,
disableTracking: true);
provider = services.BuildServiceProvider();
schema = new CapturingSchema(provider, provider.GetRequiredService<CapturingQuery>());
executer = new EfDocumentExecuter();

await Full();
var captured = CapturingQuery.Captured!;
var model = sqlInstance.Model;
var includeAppender = new IncludeAppender(
NavigationReader.GetNavigationProperties(model),
model.GetKeyNames(),
ForeignKeyExtractor.GetForeignKeyProperties(model),
new Dictionary<Type, IReadOnlyList<Type>>());
projected = includeAppender.ApplyProjection<BenchmarkDbContext, ParentEntity>(captured, null, context.Parents.AsNoTracking());
}

[Benchmark(Baseline = true)]
public async Task<int> Full()
{
var result = await executer.ExecuteAsync(options =>
{
options.Schema = schema;
options.Query = simpleQuery;
options.RequestServices = provider;
});

if (result.Errors is { Count: > 0 })
{
throw new(string.Join(Environment.NewLine, result.Errors.Select(_ => _.Message)));
}

var data = (IDictionary<string, object?>) ((GraphQL.Execution.ExecutionNode) result.Data!).ToValue()!;
var parents = (IEnumerable<object>) data["parents"]!;
return parents.Count();
}

[Benchmark]
public Task<List<ParentEntity>> EfExecute() =>
projected.ToListAsync();

[GlobalCleanup]
public async Task Cleanup()
{
await database.DisposeAsync();
(provider as IDisposable)?.Dispose();
}
}
1 change: 1 addition & 0 deletions src/GraphQL.EntityFramework/InternalsVisibleTo.cs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
[assembly: InternalsVisibleTo("GraphQL.EntityFramework.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001006944a8a4c5de92d68196a123157958d8f212381e1e21a772626e9c86cf8032f457f5b3d669045c4183f4d8dc89be3ae953ccba62b7522b2633156d0c886597509d50de36ce53cac9eebae9146e061bd6933499c740f56770c9ca3052f51de162791f86ea316ae4e3345c58b53e5743a3301359820655979aafefb40384c585c5")]
[assembly: InternalsVisibleTo("Benchmarks, PublicKey=00240000048000009400000006020000002400005253413100040000010001006944a8a4c5de92d68196a123157958d8f212381e1e21a772626e9c86cf8032f457f5b3d669045c4183f4d8dc89be3ae953ccba62b7522b2633156d0c886597509d50de36ce53cac9eebae9146e061bd6933499c740f56770c9ca3052f51de162791f86ea316ae4e3345c58b53e5743a3301359820655979aafefb40384c585c5")]
Loading
Loading