diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a8c57d1f..7949ee00 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -40,6 +40,10 @@ jobs:
- name: Test
run: dotnet test Linq2GraphQL.CI.slnf --no-build --verbosity normal
+ # Package validation (src/Directory.Build.targets) fails here on a public API break.
+ - name: Pack
+ run: dotnet pack Linq2GraphQL.Release.slnf --no-build -c Debug -o artifacts
+
generated-clients:
name: Test clients up to date
diff --git a/docs/Linq2GraphQL.Docs/Pages/Batching.razor b/docs/Linq2GraphQL.Docs/Pages/Batching.razor
new file mode 100644
index 00000000..a44a74a7
--- /dev/null
+++ b/docs/Linq2GraphQL.Docs/Pages/Batching.razor
@@ -0,0 +1,326 @@
+@page "/batching"
+
+Batching - Linq2GraphQL
+
+
Batching
+
+ A page often needs several unrelated queries. A batch runs them together: you add each query with a callback
+ for its result, and the batch merges them into as few requests as possible, sends those in parallel and hands
+ every result to its callback.
+
+
+
+ Run queries together
+
+ Start a batch with CreateBatch() on the generated client, add queries with Query
+ and finish each with OnResult instead of ExecuteAsync. One
+ ExecuteAsync on the batch runs them all:
+
+
+
+
+ The three queries travel as one GraphQL operation. Each root field gets an alias, b0,
+ b1 and so on, and its variables the alias as a prefix, so queries for the same field with
+ different arguments can't collide:
+
+
+
+ -
+ The lambda passed to
Query receives the client and runs when the batch executes, not
+ when you add it. It may return null to add nothing.
+
+ -
+ Callbacks run after every response has arrived, one at a time, in the order the queries were added.
+ Assigning to local variables, as above, is safe.
OnResultAsync takes an async callback
+ and awaits it before the next one.
+
+ -
+
OnResult wraps the query in a batch item without changing the query, so the same query can
+ still be executed on its own or added again. Each item belongs to one batch, and a batch executes once.
+
+ -
+
CreateBatch works on a generated client class. Through a client interface, such as one
+ injected as IStarWarsClient, use GraphBatch.Create(client) instead.
+
+
+
+
+
+ Many queries
+
+ Batches are built in code, so a loop can add one query per item. MaxItemsPerRequest caps how
+ many queries go into one request; the rest go into further requests, which are sent in parallel:
+
+
+
+
+ Six films with a cap of three make two requests. Their responses can arrive in any order, but the
+ callbacks still run in the order the films were added.
+
+
+
+
+ Conditional queries
+
+ QueryIf adds a query only when its condition holds, so the chain doesn't need if
+ blocks around it. The condition is a bool, or a Func<bool> that is
+ evaluated when the batch executes:
+
+
+
+
+
+
+ Mutations and several clients
+
+ Queries and mutations can't share an operation, so a batch that has both sends them separately. Mutations
+ are merged too, but their requests are sent one after another, and the server runs the fields of one
+ mutation in order, so a batch of mutations runs in the order you added them.
+
+
+ Each query is sent with the client it was built from. A query built from a client derived with
+ WithAccessToken or WithRequestConfiguration goes into a request of its own, with
+ that client's headers.
+
+
+
+
+ Errors
+
+ Like a single query, ExecuteAsync throws when something failed, and
+ ExecuteWithResultAsync returns the errors instead. Either way, a failure in one query doesn't
+ cost the others their results:
+
+
+
+
+ Each error's path starts with its query's alias, so b1 above is the second query added; queries
+ that QueryIf skipped still count. With ExecuteAsync the callbacks run first and the
+ exception comes after them:
+
+
+
+
+ | Failure | Callback | ExecuteAsync throws | ExecuteWithResultAsync |
+
+
+
+ | A field returns an error |
+ Runs, with the data that came back (often null) |
+ GraphQueryExecutionException |
+ Errors |
+
+
+
+ The server returns "data": null, because a non-null root field failed, or rejects
+ the request before running it
+ |
+ Doesn't run for any query in that request |
+ GraphQueryExecutionException |
+ Errors |
+
+
+ | The HTTP request fails or times out |
+ Doesn't run for any query in that request |
+ GraphQueryRequestException or TaskCanceledException |
+ Failures |
+
+
+ | The batch is cancelled |
+ No callback runs |
+ OperationCanceledException |
+ Throws as well |
+
+
+
+
+
+ In Parallel mode every query is its own request, so a query whose non-null root field fails
+ only loses its own result, and its callback runs with null.
+
+
+ When several requests of a batch fail, ExecuteAsync throws an AggregateException
+ holding one exception per request. GraphBatchResult.EnsureNoErrors() throws the same way.
+ See error handling for the exceptions themselves.
+
+
+
+
+ Server limits
+
+ Servers limit how large or how costly a request may be, and a merged request grows with every query in it.
+ GraphQL has no way to ask a server for its limits, so the batch finds out: when the server rejects a request
+ before running it (an HTTP 413, or errors without a path and no data, which is how size and cost limits
+ answer), the batch sends its queries again in two halves, down to single queries if it has to. Nothing ran,
+ so resending is safe, for mutations too.
+
+
+ When every query got through once the requests were small enough, the client remembers the largest size
+ that was accepted, and later batches on the same client start there. A query that is rejected even on its
+ own fails with the server's error, and then nothing is remembered: the rejection was about that query, not
+ about size. Set SplitOnRejection to false to report rejections without splitting.
+
+
+
+
+ Options
+ Pass a callback to CreateBatch to change them:
+
+
+
+
+ | Option | Default | What it does |
+
+
+
+ Mode |
+ SingleRequest |
+
+ SingleRequest merges queries into as few requests as possible.
+ Parallel sends every query as its own request, so a failure, or a slow field,
+ affects only its own query.
+ |
+
+
+ MaxItemsPerRequest |
+ 100 |
+ The most queries merged into one request. |
+
+
+ MaxDegreeOfParallelism |
+ 4 |
+ The most requests in flight at once. Mutations are always sent one at a time. |
+
+
+ SplitOnRejection |
+ true |
+ Split a request the server rejects into halves and send them again. |
+
+
+
+
+
+
+@code {
+ private const string FirstBatchCSharp = """
+ string title = null;
+ string name = null;
+ List planets = null;
+
+ await starWarsClient.CreateBatch()
+ .Query(c => c.Query.Film(filmID: new ID { Value = "1" })
+ .Select(f => f.Title)
+ .OnResult(r => title = r))
+ .Query(c => c.Query.Person(personID: new ID { Value = "1" })
+ .Select(p => p.Name)
+ .OnResult(r => name = r))
+ .Query(c => c.Query.AllPlanets(first: 3)
+ .Select(e => e.Planets.Select(p => p.Name).ToList())
+ .OnResult(r => planets = r))
+ .ExecuteAsync();
+
+ Console.WriteLine(title);
+ Console.WriteLine(name);
+ Console.WriteLine(string.Join(", ", planets));
+ """;
+
+ private const string FirstBatchOutput = """
+ A New Hope
+ Luke Skywalker
+ Tatooine, Alderaan, Yavin IV
+ """;
+
+ private const string FirstBatchQuery = """
+ query($b0_filmID_1_1: ID $b1_personID_1_1: ID $b2_first_1_1: Int) {
+ b0: film(filmID: $b0_filmID_1_1) { title }
+ b1: person(personID: $b1_personID_1_1) { name }
+ b2: allPlanets(first: $b2_first_1_1) { planets { name } }
+ }
+ """;
+
+ private const string LoopCSharp = """
+ var titles = new Dictionary();
+ var batch = starWarsClient.CreateBatch(o => o.MaxItemsPerRequest = 3);
+
+ foreach (var id in new[] { "1", "2", "3", "4", "5", "6" })
+ {
+ batch.Query(c => c.Query.Film(filmID: new ID { Value = id })
+ .Select(f => f.Title)
+ .OnResult(r => titles[id] = r));
+ }
+
+ await batch.ExecuteAsync();
+
+ foreach (var (id, title) in titles)
+ {
+ Console.WriteLine($"{id}: {title}");
+ }
+ """;
+
+ private const string LoopOutput = """
+ 1: A New Hope
+ 2: The Empire Strikes Back
+ 3: Return of the Jedi
+ 4: The Phantom Menace
+ 5: Attack of the Clones
+ 6: Revenge of the Sith
+ """;
+
+ private const string ConditionalCSharp = """
+ var includeCrawl = false;
+ string director = null;
+ string crawl = null;
+
+ await starWarsClient.CreateBatch()
+ .Query(c => c.Query.Film(filmID: new ID { Value = "1" })
+ .Select(f => f.Director)
+ .OnResult(r => director = r))
+ .QueryIf(c => c.Query.Film(filmID: new ID { Value = "1" })
+ .Select(f => f.OpeningCrawl)
+ .OnResult(r => crawl = r), includeCrawl)
+ .ExecuteAsync();
+
+ Console.WriteLine($"{director} / crawl loaded: {crawl != null}");
+ """;
+
+ private const string ConditionalOutput = """
+ George Lucas / crawl loaded: False
+ """;
+
+ private const string ResultCSharp = """
+ string found = null;
+ string missing = "not called";
+
+ var result = await starWarsClient.CreateBatch()
+ .Query(c => c.Query.Film(filmID: new ID { Value = "1" })
+ .Select(f => f.Title)
+ .OnResult(r => found = r))
+ .Query(c => c.Query.Film(filmID: new ID { Value = "99" })
+ .Select(f => f.Title)
+ .OnResult(r => missing = r))
+ .ExecuteWithResultAsync();
+
+ Console.WriteLine(found);
+ Console.WriteLine(missing ?? "null");
+ foreach (var error in result.Errors)
+ {
+ Console.WriteLine($"{error.Message} (path: {string.Join("/", error.Path)})");
+ }
+ """;
+
+ private const string ResultOutput = """
+ A New Hope
+ null
+ No entry in local cache for https://www.swapi.tech/api/films/99 (path: b1)
+ """;
+
+ private const string OptionsCSharp = """
+ var batch = starWarsClient.CreateBatch(options =>
+ {
+ options.Mode = GraphBatchMode.SingleRequest;
+ options.MaxItemsPerRequest = 50;
+ options.MaxDegreeOfParallelism = 2;
+ options.SplitOnRejection = true;
+ });
+ """;
+}
diff --git a/docs/Linq2GraphQL.Docs/Pages/ErrorHandling.razor b/docs/Linq2GraphQL.Docs/Pages/ErrorHandling.razor
index c5212de8..21ab7567 100644
--- a/docs/Linq2GraphQL.Docs/Pages/ErrorHandling.razor
+++ b/docs/Linq2GraphQL.Docs/Pages/ErrorHandling.razor
@@ -79,7 +79,8 @@
The same choice exists for paging (NextPageWithResultAsync,
- GetAllPagesWithResultAsync) and subscriptions
+ GetAllPagesWithResultAsync), batches
+ (ExecuteWithResultAsync) and subscriptions
(StartWithErrorsAsync).
diff --git a/docs/Linq2GraphQL.Docs/Pages/GettingStarted.razor b/docs/Linq2GraphQL.Docs/Pages/GettingStarted.razor
index 1b7cf889..0328ee3b 100644
--- a/docs/Linq2GraphQL.Docs/Pages/GettingStarted.razor
+++ b/docs/Linq2GraphQL.Docs/Pages/GettingStarted.razor
@@ -158,7 +158,7 @@
Next steps
diff --git a/docs/Linq2GraphQL.Docs/Pages/Index.razor b/docs/Linq2GraphQL.Docs/Pages/Index.razor
index a9d8a503..423d5283 100644
--- a/docs/Linq2GraphQL.Docs/Pages/Index.razor
+++ b/docs/Linq2GraphQL.Docs/Pages/Index.razor
@@ -187,7 +187,8 @@
Getting started walks through generating a client, registering it and running a first query.
The guides go deeper: selecting fields, error handling,
- paging, subscriptions and agent mode.
+ paging, batching, subscriptions and
+ agent mode.
The generator reference covers every command line option, the configuration file and scalar mappings.
diff --git a/docs/Linq2GraphQL.Docs/Shared/MainNavigation.razor b/docs/Linq2GraphQL.Docs/Shared/MainNavigation.razor
index 56a6dbc0..a0c49111 100644
--- a/docs/Linq2GraphQL.Docs/Shared/MainNavigation.razor
+++ b/docs/Linq2GraphQL.Docs/Shared/MainNavigation.razor
@@ -30,6 +30,7 @@
+
diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets
new file mode 100644
index 00000000..a2cf7569
--- /dev/null
+++ b/src/Directory.Build.targets
@@ -0,0 +1,11 @@
+
+
+
+ true
+ 1.0.67-prerelease
+
+
diff --git a/src/Linq2GraphQL.Client/Batching/GraphBatch.cs b/src/Linq2GraphQL.Client/Batching/GraphBatch.cs
new file mode 100644
index 00000000..98369bf9
--- /dev/null
+++ b/src/Linq2GraphQL.Client/Batching/GraphBatch.cs
@@ -0,0 +1,54 @@
+namespace Linq2GraphQL.Client;
+
+public static class GraphBatch
+{
+ ///
+ /// Starts a batch of queries on : add them with
+ /// , then run them all with
+ /// .
+ ///
+ public static IGraphBatchBuilder CreateBatch(this TClient client,
+ Action configure = null)
+ where TClient : IGraphClientSource
+ {
+ return Create(client, configure);
+ }
+
+ ///
+ /// Like , for a client of any type, such as a generated client's interface.
+ ///
+ public static IGraphBatchBuilder Create(TClient client,
+ Action configure = null)
+ {
+ ArgumentNullException.ThrowIfNull(client);
+
+ var options = new GraphBatchOptions();
+ configure?.Invoke(options);
+ options.Validate();
+
+ return new GraphBatchBuilder(client, options);
+ }
+}
+
+public interface IGraphBatchBuilder
+{
+ ///
+ /// Adds a query. runs when the batch executes; it may return null to
+ /// add nothing.
+ ///
+ IGraphBatchBuilder Query(Func itemFactory);
+
+ IGraphBatchBuilder QueryIf(Func itemFactory, bool condition);
+
+ /// Adds a query when , evaluated when the batch executes, is true.
+ IGraphBatchBuilder QueryIf(Func itemFactory, Func condition);
+
+ ///
+ /// Sends the batch, invokes each item's callback in the order the items were added, then throws if any
+ /// request failed or returned errors (see ).
+ ///
+ Task ExecuteAsync(CancellationToken cancellationToken = default);
+
+ /// Like , but returns the errors instead of throwing them.
+ Task ExecuteWithResultAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/Linq2GraphQL.Client/Batching/GraphBatchBuilder.cs b/src/Linq2GraphQL.Client/Batching/GraphBatchBuilder.cs
new file mode 100644
index 00000000..89199776
--- /dev/null
+++ b/src/Linq2GraphQL.Client/Batching/GraphBatchBuilder.cs
@@ -0,0 +1,441 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json;
+using Linq2GraphQL.Client.Common;
+
+namespace Linq2GraphQL.Client;
+
+internal sealed class GraphBatchBuilder(TClient client, GraphBatchOptions options) : IGraphBatchBuilder
+{
+ private const int MaxErrorMessageLength = 4096;
+
+ private readonly List> itemFactories = [];
+ private readonly List<(int Order, GraphQueryExecutionException Exception)> executionErrors = [];
+ private readonly List<(int Order, Exception Exception)> failures = [];
+ private readonly Dictionary splitTrackers = [];
+ private bool executed;
+
+ public IGraphBatchBuilder Query(Func itemFactory)
+ {
+ ArgumentNullException.ThrowIfNull(itemFactory);
+ itemFactories.Add(() => itemFactory(client));
+ return this;
+ }
+
+ public IGraphBatchBuilder QueryIf(Func itemFactory, bool condition)
+ {
+ ArgumentNullException.ThrowIfNull(itemFactory);
+ itemFactories.Add(() => condition ? itemFactory(client) : null);
+ return this;
+ }
+
+ public IGraphBatchBuilder QueryIf(Func itemFactory, Func condition)
+ {
+ ArgumentNullException.ThrowIfNull(itemFactory);
+ ArgumentNullException.ThrowIfNull(condition);
+ itemFactories.Add(() => condition() ? itemFactory(client) : null);
+ return this;
+ }
+
+ public async Task ExecuteAsync(CancellationToken cancellationToken = default)
+ {
+ var result = await ExecuteWithResultAsync(cancellationToken);
+ result.EnsureNoErrors();
+ }
+
+ public async Task ExecuteWithResultAsync(CancellationToken cancellationToken = default)
+ {
+ if (executed)
+ {
+ throw new InvalidOperationException("A batch can only be executed once.");
+ }
+
+ executed = true;
+
+ var items = CreateItems();
+ var order = items.Select((item, index) => (item, index)).ToDictionary(e => e.item, e => e.index);
+
+ foreach (var phase in SplitIntoPhases(items))
+ {
+ if (options.Mode == GraphBatchMode.Parallel)
+ {
+ await ExecuteEachAsync(phase, order, cancellationToken);
+ }
+ else
+ {
+ await ExecuteMergedAsync(phase, order, cancellationToken);
+ }
+ }
+
+ foreach (var (graphClient, tracker) in splitTrackers)
+ {
+ if (tracker.LearnedSize is { } size)
+ {
+ graphClient.RememberAcceptedBatchSize(size);
+ }
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ foreach (var item in items.Where(item => item.Responded))
+ {
+ await item.CompleteAsync();
+ }
+
+ return new GraphBatchResult(
+ executionErrors.OrderBy(e => e.Order).Select(e => e.Exception).ToList(),
+ failures.OrderBy(f => f.Order).Select(f => f.Exception).ToList());
+ }
+
+ ///
+ /// Runs the factories. An item's alias comes from the position it was added at, skipped ones included, so
+ /// b1 in an error path is always the second query added.
+ ///
+ private List CreateItems()
+ {
+ var items = new List();
+ for (var index = 0; index < itemFactories.Count; index++)
+ {
+ var item = itemFactories[index]();
+ if (item == null)
+ {
+ continue;
+ }
+
+ if (item.Alias != null)
+ {
+ throw new InvalidOperationException(
+ "The same batch item was added twice. Call OnResult once for every query added.");
+ }
+
+ item.Alias = "b" + index;
+ items.Add(item);
+ }
+
+ return items;
+ }
+
+ ///
+ /// Consecutive runs of queries and of mutations, in the order they were added: a query added after a
+ /// mutation is sent after it.
+ ///
+ private static IEnumerable> SplitIntoPhases(List items)
+ {
+ var phase = new List();
+ foreach (var item in items)
+ {
+ if (phase.Count > 0 && phase[0].OperationType != item.OperationType)
+ {
+ yield return phase;
+ phase = [];
+ }
+
+ phase.Add(item);
+ }
+
+ if (phase.Count > 0)
+ {
+ yield return phase;
+ }
+ }
+
+ private async Task ExecuteEachAsync(List phase, IReadOnlyDictionary order,
+ CancellationToken cancellationToken)
+ {
+ if (phase[0].OperationType == OperationType.Mutation)
+ {
+ foreach (var item in phase)
+ {
+ await ExecuteStandaloneAsync(item, order[item], cancellationToken);
+ }
+
+ return;
+ }
+
+ await Parallel.ForEachAsync(phase, CreateParallelOptions(cancellationToken),
+ (item, token) => new ValueTask(ExecuteStandaloneAsync(item, order[item], token)));
+ }
+
+ private async Task ExecuteStandaloneAsync(GraphBatchItem item, int order, CancellationToken cancellationToken)
+ {
+ GraphQLRequest request;
+ List errors;
+ try
+ {
+ (request, errors) = await item.ExecuteStandaloneAsync(cancellationToken);
+ }
+ catch (Exception exception) when (!cancellationToken.IsCancellationRequested)
+ {
+ AddFailure(order, exception);
+ return;
+ }
+
+ item.Responded = true;
+ if (errors.Count > 0)
+ {
+ AddExecutionError(order, new GraphQueryExecutionException(errors, request.Query, request.Variables));
+ }
+ }
+
+ private async Task ExecuteMergedAsync(List phase, IReadOnlyDictionary order,
+ CancellationToken cancellationToken)
+ {
+ foreach (var item in phase)
+ {
+ await item.InitializeAsync(cancellationToken);
+ }
+
+ var operationType = phase[0].OperationType;
+ var chunks = phase
+ .GroupBy(item => item.Client)
+ .SelectMany(group => group.Chunk(Math.Min(options.MaxItemsPerRequest,
+ group.Key.AcceptedBatchSize ?? int.MaxValue)))
+ .ToList();
+
+ if (operationType == OperationType.Mutation)
+ {
+ foreach (var chunk in chunks)
+ {
+ await SendChunkAsync(operationType, chunk, false, order, cancellationToken);
+ }
+
+ return;
+ }
+
+ await Parallel.ForEachAsync(chunks, CreateParallelOptions(cancellationToken),
+ (chunk, token) => new ValueTask(SendChunkAsync(operationType, chunk, false, order, token)));
+ }
+
+ private async Task SendChunkAsync(OperationType operationType, IReadOnlyList chunk,
+ bool isSplit, IReadOnlyDictionary order, CancellationToken cancellationToken)
+ {
+ var graphClient = chunk[0].Client;
+ var tracker = GetSplitTracker(graphClient);
+ var request = CreateRequest(operationType, chunk);
+ var chunkOrder = order[chunk[0]];
+
+ BatchEnvelope envelope;
+ try
+ {
+ envelope = await SendAsync(graphClient, request, chunk, cancellationToken);
+ }
+ catch (BatchRejectedException rejected) when (!cancellationToken.IsCancellationRequested)
+ {
+ if (CanSplit(chunk))
+ {
+ await SendHalvesAsync(operationType, chunk, tracker, order, cancellationToken);
+ return;
+ }
+
+ tracker.RejectedOnItsOwn(chunk.Count);
+ AddFailure(chunkOrder, rejected.InnerException);
+ return;
+ }
+ catch (Exception exception) when (!cancellationToken.IsCancellationRequested)
+ {
+ AddFailure(chunkOrder, exception);
+ return;
+ }
+
+ if (IsRejection(envelope))
+ {
+ if (CanSplit(chunk))
+ {
+ await SendHalvesAsync(operationType, chunk, tracker, order, cancellationToken);
+ return;
+ }
+
+ tracker.RejectedOnItsOwn(chunk.Count);
+ }
+ else if (isSplit)
+ {
+ tracker.Accepted(chunk.Count);
+ }
+
+ foreach (var item in chunk)
+ {
+ item.Responded = envelope.HasData;
+ }
+
+ if (envelope.Errors.Count > 0)
+ {
+ AddExecutionError(chunkOrder,
+ new GraphQueryExecutionException(envelope.Errors, request.Query, request.Variables));
+ }
+ }
+
+ private bool CanSplit(IReadOnlyList chunk)
+ {
+ return options.SplitOnRejection && chunk.Count > 1;
+ }
+
+ private async Task SendHalvesAsync(OperationType operationType, IReadOnlyList chunk,
+ SplitTracker tracker, IReadOnlyDictionary order, CancellationToken cancellationToken)
+ {
+ tracker.Split();
+ var half = (chunk.Count + 1) / 2;
+
+ await SendChunkAsync(operationType, chunk.Take(half).ToList(), true, order, cancellationToken);
+ await SendChunkAsync(operationType, chunk.Skip(half).ToList(), true, order, cancellationToken);
+ }
+
+ private SplitTracker GetSplitTracker(GraphClient graphClient)
+ {
+ lock (splitTrackers)
+ {
+ if (!splitTrackers.TryGetValue(graphClient, out var tracker))
+ {
+ tracker = new SplitTracker();
+ splitTrackers[graphClient] = tracker;
+ }
+
+ return tracker;
+ }
+ }
+
+ /// Errors without a path and no data: the server refused the request before executing it.
+ private static bool IsRejection(BatchEnvelope envelope)
+ {
+ return !envelope.HasData && envelope.Errors.Count > 0 && envelope.Errors.All(e => e.Path is not { Count: > 0 });
+ }
+
+ private static GraphQLRequest CreateRequest(OperationType operationType, IReadOnlyList chunk)
+ {
+ var variables = chunk.SelectMany(item => item.GetVariables()).ToList();
+ var selections = new StringBuilder();
+ foreach (var item in chunk)
+ {
+ selections.Append(item.GetSelectionText());
+ }
+
+ return new GraphQLRequest
+ {
+ Query = Utilities.GetOperationText(operationType,
+ variables.Select(variable => (variable.Name, variable.GraphType)).ToList(), selections.ToString()),
+ Variables = variables.ToDictionary(variable => variable.Name, variable => variable.Value)
+ };
+ }
+
+ private static async Task SendAsync(GraphClient graphClient, GraphQLRequest graphRequest,
+ IReadOnlyList chunk, CancellationToken cancellationToken)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Post, "")
+ {
+ Content = JsonContent.Create(graphRequest, options: graphClient.SerializerOptions)
+ };
+ await graphClient.ConfigureRequestAsync(request, cancellationToken);
+
+ using var response = await graphClient.HttpClient.SendAsync(request, cancellationToken);
+ var content = await response.Content.ReadAsByteArrayAsync(cancellationToken);
+ var slots = chunk.ToDictionary(item => item.Alias, IGraphBatchSlot (item) => item);
+
+ BatchEnvelope envelope = null;
+ try
+ {
+ envelope = GraphEnvelopeReader.ReadBatch(content, slots, graphClient.SerializerOptions);
+ }
+ catch (JsonException)
+ {
+ }
+
+ // A server that rejects a request (validation, a cost limit) may answer with a non-success status and a
+ // regular GraphQL body: that body is a result, not a transport failure.
+ if (envelope is { IsEnvelope: true } && (envelope.HasData || envelope.Errors.Count > 0))
+ {
+ return envelope;
+ }
+
+ var reason = response.IsSuccessStatusCode
+ ? $"The response is not a GraphQL response, it has neither data nor errors. Status code {response.StatusCode} Body: "
+ : $"Http error! Status code {response.StatusCode} Error: ";
+ var failure = new GraphQueryRequestException(reason + Truncate(Encoding.UTF8.GetString(content)),
+ graphRequest.Query, graphRequest.Variables);
+
+ if (response.StatusCode == HttpStatusCode.RequestEntityTooLarge)
+ {
+ throw new BatchRejectedException(failure);
+ }
+
+ throw failure;
+ }
+
+ private static string Truncate(string value)
+ {
+ return value.Length <= MaxErrorMessageLength ? value : value[..MaxErrorMessageLength] + "...(truncated)";
+ }
+
+ private ParallelOptions CreateParallelOptions(CancellationToken cancellationToken)
+ {
+ return new ParallelOptions
+ {
+ MaxDegreeOfParallelism = options.MaxDegreeOfParallelism, CancellationToken = cancellationToken
+ };
+ }
+
+ private void AddExecutionError(int order, GraphQueryExecutionException exception)
+ {
+ lock (executionErrors)
+ {
+ executionErrors.Add((order, exception));
+ }
+ }
+
+ private void AddFailure(int order, Exception exception)
+ {
+ lock (failures)
+ {
+ failures.Add((order, exception));
+ }
+ }
+
+ private sealed class BatchRejectedException(Exception failure) : Exception(failure.Message, failure);
+
+ ///
+ /// What splitting taught about one client's server. A size is learned only when every query was accepted
+ /// once requests were small enough: a query rejected even on its own means the rejection was about that query
+ /// (an invalid field, say), not about the size of the request.
+ ///
+ private sealed class SplitTracker
+ {
+ private readonly object sync = new();
+ private bool split;
+ private bool rejectedOnItsOwn;
+ private int largestAccepted;
+
+ public int? LearnedSize
+ {
+ get
+ {
+ lock (sync)
+ {
+ return split && !rejectedOnItsOwn && largestAccepted > 0 ? largestAccepted : null;
+ }
+ }
+ }
+
+ public void Split()
+ {
+ lock (sync)
+ {
+ split = true;
+ }
+ }
+
+ public void Accepted(int size)
+ {
+ lock (sync)
+ {
+ largestAccepted = Math.Max(largestAccepted, size);
+ }
+ }
+
+ public void RejectedOnItsOwn(int size)
+ {
+ lock (sync)
+ {
+ rejectedOnItsOwn |= size == 1;
+ }
+ }
+ }
+}
diff --git a/src/Linq2GraphQL.Client/Batching/GraphBatchItem.cs b/src/Linq2GraphQL.Client/Batching/GraphBatchItem.cs
new file mode 100644
index 00000000..3a1063da
--- /dev/null
+++ b/src/Linq2GraphQL.Client/Batching/GraphBatchItem.cs
@@ -0,0 +1,90 @@
+using System.Text.Json;
+using Linq2GraphQL.Client.Common;
+
+namespace Linq2GraphQL.Client;
+
+///
+/// A query with a result callback, ready to be added to a batch. Create one with
+/// . The query itself is not changed, so it can still be
+/// executed on its own.
+///
+public abstract class GraphBatchItem : IGraphBatchSlot
+{
+ private protected GraphBatchItem() { }
+
+ /// The item's root field alias in a merged request; its variables carry it as a prefix.
+ internal string Alias { get; set; }
+
+ internal bool Responded { get; set; }
+
+ internal abstract GraphClient Client { get; }
+
+ internal abstract OperationType OperationType { get; }
+
+ internal abstract Task InitializeAsync(CancellationToken cancellationToken);
+
+ internal abstract string GetSelectionText();
+
+ internal abstract IEnumerable<(string Name, string GraphType, object Value)> GetVariables();
+
+ void IGraphBatchSlot.ReadData(ref Utf8JsonReader reader, JsonSerializerOptions options)
+ {
+ ReadData(ref reader, options);
+ }
+
+ internal abstract void ReadData(ref Utf8JsonReader reader, JsonSerializerOptions options);
+
+ /// Sends the query as its own request; returns that request and the errors it got back.
+ internal abstract Task<(GraphQLRequest Request, List Errors)> ExecuteStandaloneAsync(
+ CancellationToken cancellationToken);
+
+ internal abstract Task CompleteAsync();
+}
+
+internal sealed class QueryBatchItem(GraphQueryExecute query, Func onResult)
+ : GraphBatchItem
+{
+ private T data;
+
+ private string VariablePrefix => Alias + "_";
+
+ internal override GraphClient Client => query.Client;
+
+ internal override OperationType OperationType => query.OperationType;
+
+ internal override Task InitializeAsync(CancellationToken cancellationToken)
+ {
+ return query.InitializeAsync(cancellationToken);
+ }
+
+ internal override string GetSelectionText()
+ {
+ return query.QueryNode.GetQueryString(variablePrefix: VariablePrefix, alias: Alias);
+ }
+
+ internal override IEnumerable<(string Name, string GraphType, object Value)> GetVariables()
+ {
+ return query.QueryNode.GetAllActiveArguments()
+ .Select(argument => (VariablePrefix + argument.VariableName, argument.GraphType, argument.Value));
+ }
+
+ internal override void ReadData(ref Utf8JsonReader reader, JsonSerializerOptions options)
+ {
+ data = reader.TokenType == JsonTokenType.Null ? default : JsonSerializer.Deserialize(ref reader, options);
+ }
+
+ internal override async Task<(GraphQLRequest Request, List Errors)> ExecuteStandaloneAsync(
+ CancellationToken cancellationToken)
+ {
+ var request = await query.GetRequestAsync(cancellationToken);
+ var result = await query.ExecuteBaseWithResultAsync(request, cancellationToken);
+ data = result.Data;
+ return (request, result.Errors ?? []);
+ }
+
+ internal override Task CompleteAsync()
+ {
+ query.BaseResult = data;
+ return onResult(query.ConvertResult(data));
+ }
+}
diff --git a/src/Linq2GraphQL.Client/Batching/GraphBatchOptions.cs b/src/Linq2GraphQL.Client/Batching/GraphBatchOptions.cs
new file mode 100644
index 00000000..5085ca58
--- /dev/null
+++ b/src/Linq2GraphQL.Client/Batching/GraphBatchOptions.cs
@@ -0,0 +1,44 @@
+namespace Linq2GraphQL.Client;
+
+public enum GraphBatchMode
+{
+ ///
+ /// The batch's queries are merged into as few requests as possible, one aliased root field per query.
+ /// A non-null root field that fails nulls the whole data of its request, so every item in that
+ /// request is left without a result.
+ ///
+ SingleRequest,
+
+ ///
+ /// Every query is sent as its own request, with at most
+ /// in flight. A failing query affects only itself.
+ ///
+ Parallel
+}
+
+public sealed class GraphBatchOptions
+{
+ public GraphBatchMode Mode { get; set; } = GraphBatchMode.SingleRequest;
+
+ ///
+ /// The most queries merged into one request in mode. Choose it so
+ /// a request stays under the server's size and cost limits; see also .
+ ///
+ public int MaxItemsPerRequest { get; set; } = 100;
+
+ /// The most requests in flight at once. Mutations are always sent one at a time, in order.
+ public int MaxDegreeOfParallelism { get; set; } = 4;
+
+ ///
+ /// When the server rejects a merged request before executing it (an HTTP 413, or errors without a path and
+ /// no data, as a size or cost limit produces), send its queries again in two halves, down to single
+ /// queries. The size that was accepted is remembered by the client for later batches.
+ ///
+ public bool SplitOnRejection { get; set; } = true;
+
+ internal void Validate()
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(MaxItemsPerRequest, 1, nameof(MaxItemsPerRequest));
+ ArgumentOutOfRangeException.ThrowIfLessThan(MaxDegreeOfParallelism, 1, nameof(MaxDegreeOfParallelism));
+ }
+}
diff --git a/src/Linq2GraphQL.Client/Batching/GraphBatchResult.cs b/src/Linq2GraphQL.Client/Batching/GraphBatchResult.cs
new file mode 100644
index 00000000..112a1f62
--- /dev/null
+++ b/src/Linq2GraphQL.Client/Batching/GraphBatchResult.cs
@@ -0,0 +1,43 @@
+using System.Runtime.ExceptionServices;
+
+namespace Linq2GraphQL.Client;
+
+public sealed class GraphBatchResult
+{
+ private readonly IReadOnlyList executionErrors;
+
+ internal GraphBatchResult(IReadOnlyList executionErrors,
+ IReadOnlyList failures)
+ {
+ this.executionErrors = executionErrors;
+ Failures = failures;
+ Errors = executionErrors.SelectMany(e => e.Errors).ToList();
+ }
+
+ /// The GraphQL errors of every request in the batch; an error's path starts with its item's alias.
+ public IReadOnlyList Errors { get; }
+
+ /// Requests that got no GraphQL response, such as HTTP errors and timeouts.
+ public IReadOnlyList Failures { get; }
+
+ public bool HasErrors => Errors.Count > 0 || Failures.Count > 0;
+
+ ///
+ /// Throws when the batch had errors: the single exception as is, several as an
+ /// . GraphQL errors are a
+ /// per request.
+ ///
+ public void EnsureNoErrors()
+ {
+ var exceptions = executionErrors.Concat(Failures).ToList();
+ if (exceptions.Count == 1)
+ {
+ ExceptionDispatchInfo.Capture(exceptions[0]).Throw();
+ }
+
+ if (exceptions.Count > 1)
+ {
+ throw new AggregateException("Several requests of the batch failed.", exceptions);
+ }
+ }
+}
diff --git a/src/Linq2GraphQL.Client/Common/GraphEnvelopeReader.cs b/src/Linq2GraphQL.Client/Common/GraphEnvelopeReader.cs
index 24cc6281..0b758483 100644
--- a/src/Linq2GraphQL.Client/Common/GraphEnvelopeReader.cs
+++ b/src/Linq2GraphQL.Client/Common/GraphEnvelopeReader.cs
@@ -8,8 +8,8 @@ namespace Linq2GraphQL.Client.Common;
/// extensions). Reads directly from UTF-8 bytes: only the requested field under
/// "data" is deserialized; sibling root fields are skipped without materializing a typed
/// value. No JsonDocument and no UTF-16 round trip. Every response path (typed queries,
-/// raw GraphClient.ExecuteAsync, the public ProcessResponse overloads and subscription
-/// payloads) goes through here, so they cannot drift apart.
+/// raw GraphClient.ExecuteAsync, the public ProcessResponse overloads, subscription
+/// payloads and batches) goes through here, so they cannot drift apart.
///
internal static class GraphEnvelopeReader
{
@@ -36,15 +36,36 @@ internal static GraphResult ReadDocument(ReadOnlySpan utf8, J
return ReadCore(utf8, null, options, out isEnvelope);
}
+ ///
+ /// Reads the envelope of a batch response: each root field under data whose name is a key of
+ /// is handed to that slot; other fields are skipped.
+ ///
+ internal static BatchEnvelope ReadBatch(ReadOnlySpan utf8, IReadOnlyDictionary slots,
+ JsonSerializerOptions options)
+ {
+ var sink = new BatchDataSink(slots);
+ var (errors, extensions) = ReadEnvelope(utf8, ref sink, options, out var isEnvelope);
+ return new BatchEnvelope(isEnvelope, sink.HasData, errors ?? [], extensions);
+ }
+
/// Root field to read, or null to read the whole data value.
private static GraphResult ReadCore(ReadOnlySpan utf8, string name, JsonSerializerOptions options,
out bool isEnvelope)
+ {
+ var sink = new SingleDataSink(name);
+ var (errors, extensions) = ReadEnvelope(utf8, ref sink, options, out isEnvelope);
+ return new GraphResult { Data = sink.Data, Errors = errors, Extensions = extensions };
+ }
+
+ private static (List Errors, Dictionary Extensions) ReadEnvelope(
+ ReadOnlySpan utf8, ref TSink sink, JsonSerializerOptions options, out bool isEnvelope)
+ where TSink : struct, IDataSink
{
isEnvelope = false;
if (utf8.Length == 0)
{
- return new GraphResult { Data = default };
+ return (null, null);
}
var reader = new Utf8JsonReader(utf8, isFinalBlock: true, state: default);
@@ -53,7 +74,6 @@ private static GraphResult ReadCore(ReadOnlySpan utf8, string name,
{
List errors = null;
Dictionary extensions = null;
- T data = default;
while (reader.Read())
{
@@ -69,9 +89,7 @@ private static GraphResult ReadCore(ReadOnlySpan utf8, string name,
{
case "data":
isEnvelope = true;
- data = name == null
- ? JsonSerializer.Deserialize(ref reader, options)
- : ReadData(ref reader, name, options);
+ sink.ReadData(ref reader, options);
break;
case "errors":
isEnvelope = true;
@@ -87,7 +105,7 @@ private static GraphResult ReadCore(ReadOnlySpan utf8, string name,
}
}
- return new GraphResult { Data = data, Errors = errors, Extensions = extensions };
+ return (errors, extensions);
}
throw new JsonException("GraphQL response envelope must be a JSON object.");
@@ -136,4 +154,75 @@ private static TData ReadData(ref Utf8JsonReader reader, string name, Jso
return result;
}
+
+ private interface IDataSink
+ {
+ void ReadData(ref Utf8JsonReader reader, JsonSerializerOptions options);
+ }
+
+ private struct SingleDataSink(string name) : IDataSink
+ {
+ public T Data { get; private set; }
+
+ public void ReadData(ref Utf8JsonReader reader, JsonSerializerOptions options)
+ {
+ Data = name == null
+ ? JsonSerializer.Deserialize(ref reader, options)
+ : ReadData(ref reader, name, options);
+ }
+ }
+
+ private struct BatchDataSink(IReadOnlyDictionary slots) : IDataSink
+ {
+ public bool HasData { get; private set; }
+
+ public void ReadData(ref Utf8JsonReader reader, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.Null)
+ {
+ return;
+ }
+
+ if (reader.TokenType != JsonTokenType.StartObject)
+ {
+ throw new JsonException("The 'data' property must be a JSON object or null.");
+ }
+
+ HasData = true;
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ {
+ break;
+ }
+
+ var fieldName = reader.GetString();
+ reader.Read();
+
+ if (fieldName != null && slots.TryGetValue(fieldName, out var slot))
+ {
+ slot.ReadData(ref reader, options);
+ }
+ else
+ {
+ reader.Skip();
+ }
+ }
+ }
+ }
+}
+
+/// A root field of a batch response, deserialized by the item that asked for it.
+internal interface IGraphBatchSlot
+{
+ /// Reads the field's value; the reader is on its first token, which may be null.
+ void ReadData(ref Utf8JsonReader reader, JsonSerializerOptions options);
}
+
+/// The body carried data or errors: it is a GraphQL response.
+/// data was an object, so the operation executed (fully or in part).
+internal sealed record BatchEnvelope(
+ bool IsEnvelope,
+ bool HasData,
+ List Errors,
+ Dictionary Extensions);
diff --git a/src/Linq2GraphQL.Client/GraphBaseExecute.cs b/src/Linq2GraphQL.Client/GraphBaseExecute.cs
index b5bb481b..fe16abea 100644
--- a/src/Linq2GraphQL.Client/GraphBaseExecute.cs
+++ b/src/Linq2GraphQL.Client/GraphBaseExecute.cs
@@ -1,7 +1,6 @@
using Linq2GraphQL.Client.Schema;
using System.Collections.Generic;
using System.Linq.Expressions;
-using System.Text;
using System.Text.Json;
namespace Linq2GraphQL.Client;
@@ -62,20 +61,14 @@ private async Task InitQueryAsync(CancellationToken cancellationToken)
public GraphQLSchema Schema => schema;
- private string GetQueryVariablesString(IReadOnlyList args)
- {
- if (args.Count == 0)
- {
- return "";
- }
+ internal GraphClient Client => client;
- var text = new StringBuilder(args.Count * 32);
- foreach (var argument in args)
- {
- text.Append('$').Append(argument.VariableName).Append(": ").Append(argument.GraphType).Append(' ');
- }
+ internal OperationType OperationType => operationType;
- return $"({text})";
+ /// The one-time setup a request needs, done for a batch that renders the query itself.
+ internal Task InitializeAsync(CancellationToken cancellationToken)
+ {
+ return InitQueryAsync(cancellationToken);
}
public Task GetRequestAsJsonAsync()
@@ -160,13 +153,7 @@ public async Task GetRequestAsync(CancellationToken cancellation
private string GetGraphQLQuery(IReadOnlyList args)
{
- return GetOperationType() + GetQueryVariablesString(args) + "{ " + Environment.NewLine +
- QueryNode.GetQueryString() + Environment.NewLine + "}";
- }
-
- private string GetOperationType()
- {
- return operationType.ToString().ToLower();
+ return Utilities.GetOperationText(operationType, args, QueryNode.GetQueryString());
}
public TResult ConvertResult(T result)
diff --git a/src/Linq2GraphQL.Client/GraphClient.cs b/src/Linq2GraphQL.Client/GraphClient.cs
index d340f105..25938fe2 100644
--- a/src/Linq2GraphQL.Client/GraphClient.cs
+++ b/src/Linq2GraphQL.Client/GraphClient.cs
@@ -98,6 +98,27 @@ public Task> ExecuteAsync(string query, IDictionary options.Value;
+ private int acceptedBatchSize;
+
+ ///
+ /// The largest number of merged queries the server accepted after rejecting a bigger batch request, or null
+ /// when no batch request has been rejected yet.
+ ///
+ internal int? AcceptedBatchSize => acceptedBatchSize == 0 ? null : acceptedBatchSize;
+
+ internal void RememberAcceptedBatchSize(int size)
+ {
+ int current;
+ do
+ {
+ current = acceptedBatchSize;
+ if (current != 0 && current <= size)
+ {
+ return;
+ }
+ } while (Interlocked.CompareExchange(ref acceptedBatchSize, size, current) != current);
+ }
+
///
/// The connection_init payload delegate from ,
diff --git a/src/Linq2GraphQL.Client/GraphQueryExecute.cs b/src/Linq2GraphQL.Client/GraphQueryExecute.cs
index b89479ec..e5d3bf59 100644
--- a/src/Linq2GraphQL.Client/GraphQueryExecute.cs
+++ b/src/Linq2GraphQL.Client/GraphQueryExecute.cs
@@ -31,8 +31,13 @@ await queryExecutor.ExecuteRequestAsync(QueryNode.Name, await GetRequestAsync(ca
public async Task> ExecuteBaseWithResultAsync(CancellationToken cancellationToken = default)
{
- var result = await queryExecutor.ExecuteRawAsync(QueryNode.Name, await GetRequestAsync(cancellationToken),
- cancellationToken);
+ return await ExecuteBaseWithResultAsync(await GetRequestAsync(cancellationToken), cancellationToken);
+ }
+
+ internal async Task> ExecuteBaseWithResultAsync(GraphQLRequest request,
+ CancellationToken cancellationToken)
+ {
+ var result = await queryExecutor.ExecuteRawAsync(QueryNode.Name, request, cancellationToken);
BaseResult = result.Data;
return result;
}
@@ -46,4 +51,25 @@ public async Task> ExecuteWithResultAsync(CancellationToken
{
return ConvertResultFull(await ExecuteBaseWithResultAsync(cancellationToken));
}
+
+ ///
+ /// Makes this query an item of a batch (see ). Once the batch has executed,
+ /// receives the result; it is not called when no response arrived for the item.
+ ///
+ public GraphBatchItem OnResult(Action onResult)
+ {
+ ArgumentNullException.ThrowIfNull(onResult);
+ return new QueryBatchItem(this, result =>
+ {
+ onResult(result);
+ return Task.CompletedTask;
+ });
+ }
+
+ ///
+ public GraphBatchItem OnResultAsync(Func onResult)
+ {
+ ArgumentNullException.ThrowIfNull(onResult);
+ return new QueryBatchItem(this, onResult);
+ }
}
\ No newline at end of file
diff --git a/src/Linq2GraphQL.Client/QueryNode.cs b/src/Linq2GraphQL.Client/QueryNode.cs
index e743bedf..1251227a 100644
--- a/src/Linq2GraphQL.Client/QueryNode.cs
+++ b/src/Linq2GraphQL.Client/QueryNode.cs
@@ -183,7 +183,7 @@ public List GetAllActiveArguments()
return allArguments;
}
- private string GetArgumentString()
+ private string GetArgumentString(string variablePrefix)
{
var args = GetActiveArguments();
;
@@ -195,7 +195,7 @@ private string GetArgumentString()
var argString = "(";
foreach (var arg in args)
{
- argString += arg.GraphName + ":$" + arg.VariableName + " ";
+ argString += arg.GraphName + ":$" + variablePrefix + arg.VariableName + " ";
}
argString.TrimEnd();
@@ -205,22 +205,25 @@ private string GetArgumentString()
}
- internal string GetQueryString(string indent = "")
+ /// Put in front of every variable name, so several queries can share one document.
+ /// The alias of this node, in place of .
+ internal string GetQueryString(string indent = "", string variablePrefix = "", string alias = null)
{
string query;
var memberType = Member.GetUnderlyingType();
+ alias ??= Alias;
if (InterfaceProperty)
{
- query = "... on " + Name + GetArgumentString() + Environment.NewLine;
+ query = "... on " + Name + GetArgumentString(variablePrefix) + Environment.NewLine;
}
- else if (!string.IsNullOrWhiteSpace(Alias))
+ else if (!string.IsNullOrWhiteSpace(alias))
{
- query = Alias + ":" + Name + GetArgumentString() + Environment.NewLine;
+ query = alias + ":" + Name + GetArgumentString(variablePrefix) + Environment.NewLine;
}
else
{
- query = Name + GetArgumentString() + Environment.NewLine;
+ query = Name + GetArgumentString(variablePrefix) + Environment.NewLine;
}
if (memberType.IsListOfPrimitiveTypeOrString())
@@ -235,7 +238,7 @@ internal string GetQueryString(string indent = "")
var newIndent = " " + indent;
foreach (var childNode in ChildNodes)
{
- query += childNode.GetQueryString(newIndent);
+ query += childNode.GetQueryString(newIndent, variablePrefix);
}
}
diff --git a/src/Linq2GraphQL.Client/Utilities.cs b/src/Linq2GraphQL.Client/Utilities.cs
index 6e79df5b..5cb87fea 100644
--- a/src/Linq2GraphQL.Client/Utilities.cs
+++ b/src/Linq2GraphQL.Client/Utilities.cs
@@ -14,6 +14,41 @@ public static class Utilities
/// Separates one argument from the next so that adjacent values cannot run together.
private const char Separator = (char)31;
+ ///
+ /// The GraphQL document for one operation: its keyword, the declarations of
+ /// and , the root fields as rendered by .
+ ///
+ internal static string GetOperationText(OperationType operationType, IReadOnlyList arguments,
+ string selections)
+ {
+ return GetOperationText(operationType,
+ arguments.Select(argument => (argument.VariableName, argument.GraphType)).ToList(), selections);
+ }
+
+ /// The declared variables: their names as the selections use them, and their GraphQL types.
+ internal static string GetOperationText(OperationType operationType,
+ IReadOnlyList<(string Name, string GraphType)> variables, string selections)
+ {
+ return operationType.ToString().ToLower() + GetVariableDeclarations(variables) + "{ " + Environment.NewLine +
+ selections + Environment.NewLine + "}";
+ }
+
+ private static string GetVariableDeclarations(IReadOnlyList<(string Name, string GraphType)> variables)
+ {
+ if (variables.Count == 0)
+ {
+ return "";
+ }
+
+ var text = new StringBuilder(variables.Count * 32);
+ foreach (var (name, graphType) in variables)
+ {
+ text.Append('$').Append(name).Append(": ").Append(graphType).Append(' ');
+ }
+
+ return $"({text})";
+ }
+
///
/// Identifies a set of field arguments so that the same field requested with different arguments becomes
/// several aliased selections. The result is derived from the argument values only, because the alias has
diff --git a/test/Linq2GraphQL.Tests/Batch/BatchEnvelopeTests.cs b/test/Linq2GraphQL.Tests/Batch/BatchEnvelopeTests.cs
new file mode 100644
index 00000000..0528b155
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Batch/BatchEnvelopeTests.cs
@@ -0,0 +1,117 @@
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Linq2GraphQL.Client.Common;
+
+namespace Linq2GraphQL.Tests.Batch;
+
+public class BatchEnvelopeTests
+{
+ [Fact]
+ public void EachSlot_ReadsItsOwnField_OtherFieldsAreSkipped()
+ {
+ var first = new Slot();
+ var second = new Slot>();
+
+ var envelope = Read("""{"data":{"b0":"x","other":{"deep":[1,2]},"b1":[1,2,3]}}""",
+ ("b0", first), ("b1", second));
+
+ Assert.Equal("x", first.Value);
+ Assert.Equal([1, 2, 3], second.Value);
+ Assert.True(envelope.IsEnvelope);
+ Assert.True(envelope.HasData);
+ Assert.Empty(envelope.Errors);
+ }
+
+ [Fact]
+ public void NullField_IsHandedToItsSlot()
+ {
+ var slot = new Slot { Value = "unset" };
+
+ Read("""{"data":{"b0":null}}""", ("b0", slot));
+
+ Assert.True(slot.WasRead);
+ Assert.Null(slot.Value);
+ }
+
+ [Fact]
+ public void MissingField_LeavesItsSlotUnread()
+ {
+ var slot = new Slot();
+
+ Read("""{"data":{"b1":"x"}}""", ("b0", slot));
+
+ Assert.False(slot.WasRead);
+ }
+
+ [Fact]
+ public void ErrorsBeforeData_AreRead()
+ {
+ var slot = new Slot();
+
+ var envelope = Read("""{"errors":[{"message":"Bad","path":["b0"]}],"data":{"b0":"x"}}""", ("b0", slot));
+
+ Assert.Equal("x", slot.Value);
+ Assert.Equal("Bad", Assert.Single(envelope.Errors).Message);
+ }
+
+ [Theory]
+ [InlineData("""{"data":null,"errors":[{"message":"Bad","path":["b0"]}]}""", true)]
+ [InlineData("""{"errors":[{"message":"Rejected"}]}""", true)]
+ [InlineData("""{"extensions":{"cost":1}}""", false)]
+ [InlineData("", false)]
+ public void WithoutData_HasDataIsFalse(string json, bool isEnvelope)
+ {
+ var envelope = Read(json, ("b0", new Slot()));
+
+ Assert.False(envelope.HasData);
+ Assert.Equal(isEnvelope, envelope.IsEnvelope);
+ }
+
+ [Fact]
+ public void DataThatIsNotAnObject_Throws()
+ {
+ Assert.Throws(() => Read("""{"data":[1]}""", ("b0", new Slot())));
+ }
+
+ [Theory]
+ [InlineData("""{"data":{"b0":{"name":"Ada","age":36}}}""")]
+ [InlineData("""{"data":{"b0":null},"errors":[{"message":"Bad","path":["b0"]}],"extensions":{"x":1}}""")]
+ [InlineData("""{"data":{"other":1,"b0":{"name":"Ada"}}}""")]
+ [InlineData("""{"data":null}""")]
+ public void OneSlot_ReadsWhatTheSingleQueryReaderReads(string json)
+ {
+ var slot = new Slot();
+
+ var envelope = Read(json, ("b0", slot));
+ var single = GraphEnvelopeReader.Read(Encoding.UTF8.GetBytes(json), "b0", ClientJson.Default);
+
+ Assert.Equal(JsonSerializer.Serialize(single.Data), JsonSerializer.Serialize(slot.Value));
+ Assert.Equal(single.Errors?.Count ?? 0, envelope.Errors.Count);
+ Assert.Equal(single.Extensions?.Count, envelope.Extensions?.Count);
+ }
+
+ private static BatchEnvelope Read(string json, params (string Alias, IGraphBatchSlot Slot)[] slots)
+ {
+ return GraphEnvelopeReader.ReadBatch(Encoding.UTF8.GetBytes(json),
+ slots.ToDictionary(s => s.Alias, s => s.Slot), ClientJson.Default);
+ }
+
+ public sealed class Person
+ {
+ [JsonPropertyName("name")] public string? Name { get; set; }
+ [JsonPropertyName("age")] public int Age { get; set; }
+ }
+
+ private sealed class Slot : IGraphBatchSlot
+ {
+ public T? Value { get; set; }
+ public bool WasRead { get; private set; }
+
+ public void ReadData(ref Utf8JsonReader reader, JsonSerializerOptions options)
+ {
+ WasRead = true;
+ Value = reader.TokenType == JsonTokenType.Null ? default : JsonSerializer.Deserialize(ref reader, options);
+ }
+ }
+}
diff --git a/test/Linq2GraphQL.Tests/Batch/BatchEquivalenceTests.cs b/test/Linq2GraphQL.Tests/Batch/BatchEquivalenceTests.cs
new file mode 100644
index 00000000..aa4b5f26
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Batch/BatchEquivalenceTests.cs
@@ -0,0 +1,110 @@
+using System.Text.Json;
+using Linq2GraphQL.Client;
+using Linq2GraphQL.TestClient;
+
+namespace Linq2GraphQL.Tests.Batch;
+
+///
+/// Every query shape gives the same result whether it is executed on its own, alone in a batch, or in a batch
+/// together with all the others.
+///
+public class BatchEquivalenceTests : IDisposable
+{
+ private static readonly Dictionary Cases = new[]
+ {
+ QueryCase.Of("Hello", c => c.Query.Hello("Jocke").Select()),
+ QueryCase.Of("HelloDefault", c => c.Query.Hello().Select()),
+ QueryCase.Of("TopLevelArgument", c => c.Query.Orders(1).Select(e => e.Nodes)),
+ QueryCase.Of("ArgumentOnNestedField", c => c.Query.Orders(1)
+ .Include(e => e.Nodes.Select(n => n.OrderAddress(AddressType.Invoice)))
+ .Select(e => e.Nodes)),
+ QueryCase.Of("SameFieldWithDifferentArguments", c => c.Query.Orders()
+ .Select(e => e.Nodes.Select(n => new { A = n.OrderHello("A", 1), B = n.OrderHello("B", 2) }))),
+ QueryCase.Of("IncludeMultipleLevels", c => c.Query.Orders(1)
+ .Include(e => e.Nodes.Select(n => n.Customer.Orders))
+ .Select(e => e.Nodes)),
+ QueryCase.Of("LinqOperators", c => c.Query.Orders()
+ .Select(e => e.Nodes.Where(n => n.OrderId != Guid.Empty).Select(n => new { n.OrderId, n.Customer.CustomerName }))),
+ QueryCase.Of("FilterAndSort", c => c.Query.Orders(first: 2,
+ where: new OrderFilterInput { OrderId = new UuidOperationFilterInput { Neq = Guid.Empty } },
+ order: [new OrderSortInput { OrderDate = SortEnumType.Desc }])
+ .Select(e => e.Nodes.Select(n => new { n.OrderId, n.OrderDate }))),
+ QueryCase.Of("Paging", c => c.Query.Orders(first: 2).Select(e => new { e.PageInfo.HasNextPage, e.Nodes.Count })),
+ QueryCase.Of("Interfaces", c => c.Query.Animals()
+ .Include(e => e.Nodes.Select(n => n.Pig()))
+ .Include(e => e.Nodes.Select(n => n.Spider()))
+ .Select(e => e.Nodes)),
+ QueryCase.Of("SelectMany", c => c.Query.Customers()
+ .Select(e => e.SelectMany(cu => cu.Orders, (cu, o) => new { cu.CustomerName, o.OrderId }))),
+ QueryCase.Of("NullResult", c => c.Query.CustomerReturnNull().Select())
+ }.ToDictionary(c => c.Name);
+
+ private readonly BatchTestServer server = new();
+ private readonly SampleClient client;
+
+ public BatchEquivalenceTests()
+ {
+ client = server.CreateClient();
+ }
+
+ public void Dispose()
+ {
+ server.Dispose();
+ }
+
+ public static TheoryData CaseNames => new(Cases.Keys);
+
+ [Theory]
+ [MemberData(nameof(CaseNames))]
+ public async Task AloneInABatch_GivesTheStandaloneResult(string caseName)
+ {
+ var queryCase = Cases[caseName];
+ var standalone = await queryCase.ExecuteAsync(client);
+ string? batched = null;
+
+ await client.CreateBatch().Query(c => queryCase.ToItem(c, r => batched = r)).ExecuteAsync();
+
+ Assert.Equal(standalone, batched);
+ }
+
+ [Theory]
+ [InlineData(GraphBatchMode.SingleRequest, 100)]
+ [InlineData(GraphBatchMode.SingleRequest, 3)]
+ [InlineData(GraphBatchMode.Parallel, 100)]
+ public async Task TogetherInOneBatch_EachGivesItsStandaloneResult(GraphBatchMode mode, int maxItemsPerRequest)
+ {
+ var standalone = new Dictionary();
+ foreach (var queryCase in Cases.Values)
+ {
+ standalone[queryCase.Name] = await queryCase.ExecuteAsync(client);
+ }
+
+ var batched = new Dictionary();
+ var batch = client.CreateBatch(o =>
+ {
+ o.Mode = mode;
+ o.MaxItemsPerRequest = maxItemsPerRequest;
+ });
+ foreach (var queryCase in Cases.Values)
+ {
+ batch.Query(c => queryCase.ToItem(c, r => batched[queryCase.Name] = r));
+ }
+
+ await batch.ExecuteAsync();
+
+ Assert.Equal(standalone, batched);
+ }
+
+ private sealed record QueryCase(
+ string Name,
+ Func> ExecuteAsync,
+ Func, GraphBatchItem> ToItem)
+ {
+ public static QueryCase Of(string name, Func> build)
+ {
+ return new QueryCase(name,
+ async c => JsonSerializer.Serialize(await build(c).ExecuteAsync()),
+ (c, onResult) => build(c).OnResult(r => onResult(JsonSerializer.Serialize(r))));
+ }
+ }
+}
diff --git a/test/Linq2GraphQL.Tests/Batch/BatchRequestSnapshotTests.cs b/test/Linq2GraphQL.Tests/Batch/BatchRequestSnapshotTests.cs
new file mode 100644
index 00000000..36360068
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Batch/BatchRequestSnapshotTests.cs
@@ -0,0 +1,57 @@
+using Linq2GraphQL.Client;
+using Linq2GraphQL.TestClient;
+
+namespace Linq2GraphQL.Tests.Batch;
+
+/// Golden copies of the merged documents a batch sends.
+public class BatchRequestSnapshotTests : IDisposable
+{
+ private readonly BatchTestServer server = new();
+ private readonly SampleClient client;
+
+ public BatchRequestSnapshotTests()
+ {
+ client = server.CreateClient();
+ }
+
+ public void Dispose()
+ {
+ server.Dispose();
+ }
+
+ [Fact]
+ public async Task Queries_WithArgumentsAtSeveralLevels()
+ {
+ await client.CreateBatch()
+ .Query(c => c.Query.Hello("Jocke").Select().OnResult(_ => { }))
+ .Query(c => c.Query.Hello().Select().OnResult(_ => { }))
+ .Query(c => c.Query.Orders(1)
+ .Include(e => e.Nodes.Select(n => n.OrderHello("Jocke", 2)))
+ .Include(e => e.Nodes.Select(n => n.OrderAddress(AddressType.Invoice)))
+ .Select(e => e.Nodes)
+ .OnResult(_ => { }))
+ .Query(c => c.Query.Orders()
+ .Select(e => e.Nodes.Select(n => new { A = n.OrderHello("A", 1), B = n.OrderHello("B", 2) }))
+ .OnResult(_ => { }))
+ .ExecuteAsync();
+
+ MatchesSnapshot("Queries_WithArgumentsAtSeveralLevels");
+ }
+
+ [Fact]
+ public async Task Mutations()
+ {
+ await client.CreateBatch()
+ .Query(c => c.Mutation.SetName("A").Select().OnResult(_ => { }))
+ .Query(c => c.Mutation.SetName("B").Select().OnResult(_ => { }))
+ .ExecuteAsync();
+
+ MatchesSnapshot("Mutations");
+ }
+
+ private void MatchesSnapshot(string name)
+ {
+ var operation = Assert.Single(server.Operations);
+ Snapshot.Match(operation.Query + "\n\n" + operation.Variables, "Batch." + name);
+ }
+}
diff --git a/test/Linq2GraphQL.Tests/Batch/BatchServerLimitTests.cs b/test/Linq2GraphQL.Tests/Batch/BatchServerLimitTests.cs
new file mode 100644
index 00000000..76a222e6
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Batch/BatchServerLimitTests.cs
@@ -0,0 +1,83 @@
+using HotChocolate.Execution.Configuration;
+using Linq2GraphQL.Client;
+using Linq2GraphQL.TestClient;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Linq2GraphQL.Tests.Batch;
+
+/// Batches against the real test server with a parser limit that a large batch exceeds.
+public class BatchServerLimitTests : IDisposable
+{
+ private const int MaxAllowedFields = 12;
+
+ private readonly BatchTestServer server = new(services =>
+ services.AddGraphQLServer().ModifyParserOptions(o => o.MaxAllowedFields = MaxAllowedFields));
+
+ public void Dispose()
+ {
+ server.Dispose();
+ }
+
+ [Fact]
+ public async Task BatchOverTheLimit_IsSplit_AndEveryItemGetsItsResult()
+ {
+ var client = server.CreateClient(useSafeMode: false);
+ var results = new List();
+ var batch = client.CreateBatch();
+ for (var i = 0; i < 30; i++)
+ {
+ var name = "n" + i;
+ batch.Query(c => c.Query.Hello(name).Select().OnResult(r => results.Add(r)));
+ }
+
+ await batch.ExecuteAsync();
+
+ Assert.Equal(Enumerable.Range(0, 30).Select(i => $"Hello, n{i}!"), results);
+ Assert.True(server.Operations.Count > 1);
+ Assert.NotNull(client.GraphClient.AcceptedBatchSize);
+ Assert.True(client.GraphClient.AcceptedBatchSize <= MaxAllowedFields);
+ }
+
+ [Fact]
+ public async Task WithoutSplitting_TheServersRejectionIsReported()
+ {
+ var client = server.CreateClient(useSafeMode: false);
+ var batch = client.CreateBatch(o => o.SplitOnRejection = false);
+ for (var i = 0; i < 30; i++)
+ {
+ batch.Query(c => c.Query.Hello().Select().OnResult(_ => { }));
+ }
+
+ var result = await batch.ExecuteWithResultAsync();
+
+ Assert.True(result.HasErrors);
+ Assert.Single(server.Operations);
+ }
+}
+
+public class BatchNullableClientTests : IClassFixture
+{
+ private readonly TestClientNullable.SampleNullableClient client;
+
+ public BatchNullableClientTests(SampleNullableClientFixture fixture)
+ {
+ client = fixture.sampleClient;
+ }
+
+ [Fact]
+ public async Task NullableClient_BatchGivesTheStandaloneResults()
+ {
+ var expectedList = await client.Query.CustomerListAllNullable().Select().ExecuteAsync();
+ var expectedNested = await client.Query.CustomerListInList().Select().ExecuteAsync();
+ List? list = null;
+ List>? nested = null;
+
+ await client.CreateBatch()
+ .Query(c => c.Query.CustomerListAllNullable().Select().OnResult(r => list = r))
+ .Query(c => c.Query.CustomerListInList().Select().OnResult(r => nested = r))
+ .ExecuteAsync();
+
+ Assert.Equal(System.Text.Json.JsonSerializer.Serialize(expectedList), System.Text.Json.JsonSerializer.Serialize(list));
+ Assert.Equal(System.Text.Json.JsonSerializer.Serialize(expectedNested), System.Text.Json.JsonSerializer.Serialize(nested));
+ }
+}
diff --git a/test/Linq2GraphQL.Tests/Batch/BatchSplitTests.cs b/test/Linq2GraphQL.Tests/Batch/BatchSplitTests.cs
new file mode 100644
index 00000000..d8ac958a
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Batch/BatchSplitTests.cs
@@ -0,0 +1,333 @@
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using Linq2GraphQL.Client;
+using Linq2GraphQL.TestClient;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace Linq2GraphQL.Tests.Batch;
+
+///
+/// Batches against a scripted server that rejects requests with more root fields than it accepts, so splitting,
+/// remembered sizes and failures can be driven exactly.
+///
+public partial class BatchSplitTests
+{
+ public enum Rejection
+ {
+ PayloadTooLarge,
+ ErrorsWithStatus400,
+ ErrorsWithStatus200
+ }
+
+ [Theory]
+ [InlineData(Rejection.PayloadTooLarge)]
+ [InlineData(Rejection.ErrorsWithStatus400)]
+ [InlineData(Rejection.ErrorsWithStatus200)]
+ public async Task RejectedRequest_IsSplitUntilAccepted(Rejection rejection)
+ {
+ var server = new ScriptedServer { MaxRootFields = 3, Rejection = rejection };
+ var client = CreateClient(server);
+
+ var results = await ExecuteHellosAsync(client, 8);
+
+ Assert.Equal(Enumerable.Range(0, 8).Select(i => $"hello b{i}"), results);
+ Assert.Equal([8, 4, 2, 2, 4, 2, 2], server.RootFieldsPerRequest);
+ }
+
+ [Fact]
+ public async Task AcceptedSize_IsRememberedForTheNextBatch()
+ {
+ var server = new ScriptedServer { MaxRootFields = 3 };
+ var client = CreateClient(server);
+ await ExecuteHellosAsync(client, 8);
+ server.RootFieldsPerRequest.Clear();
+
+ await ExecuteHellosAsync(client, 8, maxDegreeOfParallelism: 1);
+
+ Assert.Equal([2, 2, 2, 2], server.RootFieldsPerRequest);
+ }
+
+ [Fact]
+ public async Task ItemRejectedOnItsOwn_FailsAlone()
+ {
+ var server = new ScriptedServer { RejectName = "huge", Rejection = Rejection.ErrorsWithStatus400 };
+ var client = CreateClient(server);
+ var results = new List();
+
+ var result = await client.CreateBatch()
+ .Query(c => c.Query.Hello("a").Select().OnResult(r => results.Add(r)))
+ .Query(c => c.Query.Hello("huge").Select().OnResult(r => results.Add(r)))
+ .Query(c => c.Query.Hello("c").Select().OnResult(r => results.Add(r)))
+ .ExecuteWithResultAsync();
+
+ Assert.Equal(["hello b0", "hello b2"], results);
+ Assert.Equal("Rejected", Assert.Single(result.Errors).Message);
+ Assert.Empty(result.Failures);
+ }
+
+ [Theory]
+ [InlineData(Rejection.ErrorsWithStatus400)]
+ [InlineData(Rejection.PayloadTooLarge)]
+ public async Task ItemRejectedOnItsOwn_DoesNotLowerTheAcceptedSize(Rejection rejection)
+ {
+ var server = new ScriptedServer { RejectName = "huge", Rejection = rejection };
+ var client = CreateClient(server);
+ var batch = client.CreateBatch();
+ for (var i = 0; i < 16; i++)
+ {
+ var name = i == 5 ? "huge" : "n" + i;
+ batch.Query(c => c.Query.Hello(name).Select().OnResult(_ => { }));
+ }
+
+ await batch.ExecuteWithResultAsync();
+
+ Assert.Null(client.GraphClient.AcceptedBatchSize);
+ }
+
+ [Fact]
+ public async Task SuccessResponseThatIsNotGraphQL_IsAFailure()
+ {
+ var server = new ScriptedServer { EmptyName = "empty" };
+ var client = CreateClient(server);
+ var called = false;
+
+ var result = await client.CreateBatch()
+ .Query(c => c.Query.Hello("empty").Select().OnResult(_ => called = true))
+ .ExecuteWithResultAsync();
+
+ Assert.False(called);
+ var failure = Assert.IsType(Assert.Single(result.Failures));
+ Assert.Contains("not a GraphQL response", failure.Message);
+ }
+
+ [Fact]
+ public async Task ItemTooLargeOnItsOwn_IsAFailure()
+ {
+ var server = new ScriptedServer { RejectName = "huge", Rejection = Rejection.PayloadTooLarge };
+ var client = CreateClient(server);
+
+ var result = await client.CreateBatch()
+ .Query(c => c.Query.Hello("a").Select().OnResult(_ => { }))
+ .Query(c => c.Query.Hello("huge").Select().OnResult(_ => { }))
+ .ExecuteWithResultAsync();
+
+ var failure = Assert.IsType(Assert.Single(result.Failures));
+ Assert.Contains("RequestEntityTooLarge", failure.Message);
+ Assert.Contains("b1:hello", failure.GraphQLQuery);
+ }
+
+ [Fact]
+ public async Task SplitOnRejectionOff_FailsTheWholeRequest()
+ {
+ var server = new ScriptedServer { MaxRootFields = 1, Rejection = Rejection.ErrorsWithStatus400 };
+ var client = CreateClient(server);
+ var called = false;
+
+ var result = await client.CreateBatch(o => o.SplitOnRejection = false)
+ .Query(c => c.Query.Hello("a").Select().OnResult(_ => called = true))
+ .Query(c => c.Query.Hello("b").Select().OnResult(_ => called = true))
+ .ExecuteWithResultAsync();
+
+ Assert.False(called);
+ Assert.Single(result.Errors);
+ Assert.Single(server.RootFieldsPerRequest);
+ }
+
+ [Fact]
+ public async Task TransportFailure_FailsItsChunk_OtherChunksStillMap()
+ {
+ var server = new ScriptedServer { FailName = "boom" };
+ var client = CreateClient(server);
+ var results = new List();
+
+ var exception = await Assert.ThrowsAsync(() => client
+ .CreateBatch(o => o.MaxItemsPerRequest = 1)
+ .Query(c => c.Query.Hello("a").Select().OnResult(r => results.Add(r)))
+ .Query(c => c.Query.Hello("boom").Select().OnResult(r => results.Add(r)))
+ .ExecuteAsync());
+
+ Assert.Equal(["hello b0"], results);
+ Assert.Contains("InternalServerError", exception.Message);
+ }
+
+ [Fact]
+ public async Task ErrorsInSeveralRequests_ThrowAnAggregateException()
+ {
+ var server = new ScriptedServer { FieldErrorName = "bad" };
+ var client = CreateClient(server);
+
+ var exception = await Assert.ThrowsAsync(() => client
+ .CreateBatch(o => o.MaxItemsPerRequest = 1)
+ .Query(c => c.Query.Hello("bad").Select().OnResult(_ => { }))
+ .Query(c => c.Query.Hello("bad").Select().OnResult(_ => { }))
+ .ExecuteAsync());
+
+ Assert.Equal(2, exception.InnerExceptions.Count);
+ Assert.All(exception.InnerExceptions, e => Assert.IsType(e));
+ }
+
+ [Fact]
+ public async Task DataNullWithAPathError_LeavesEveryItemOfTheRequestWithoutAResult()
+ {
+ var server = new ScriptedServer { NullDataName = "bad" };
+ var client = CreateClient(server);
+ var called = false;
+
+ var result = await client.CreateBatch()
+ .Query(c => c.Query.Hello("a").Select().OnResult(_ => called = true))
+ .Query(c => c.Query.Hello("bad").Select().OnResult(_ => called = true))
+ .ExecuteWithResultAsync();
+
+ Assert.False(called);
+ Assert.Equal("b1", Assert.Single(result.Errors).Path[0].ToString());
+ Assert.Single(server.RootFieldsPerRequest);
+ }
+
+ [Fact]
+ public async Task MutationRejected_IsSplitAndSentInOrder()
+ {
+ var server = new ScriptedServer { MaxRootFields = 1, Rejection = Rejection.ErrorsWithStatus400 };
+ var client = CreateClient(server);
+ var results = new List();
+
+ await client.CreateBatch()
+ .Query(c => c.Mutation.SetName("a").Select().OnResult(r => results.Add(r)))
+ .Query(c => c.Mutation.SetName("b").Select().OnResult(r => results.Add(r)))
+ .Query(c => c.Mutation.SetName("c").Select().OnResult(r => results.Add(r)))
+ .ExecuteAsync();
+
+ Assert.Equal(["setName b0", "setName b1", "setName b2"], results);
+ Assert.Equal(["b0", "b1", "b2"], server.AcceptedAliases);
+ }
+
+ [Fact]
+ public async Task CancellationWhileSending_InvokesNoCallbacks()
+ {
+ var server = new ScriptedServer { HoldRequests = true };
+ var client = CreateClient(server);
+ var called = false;
+ using var cancellation = new CancellationTokenSource();
+
+ var execution = client.CreateBatch()
+ .Query(c => c.Query.Hello("a").Select().OnResult(_ => called = true))
+ .ExecuteAsync(cancellation.Token);
+ await server.RequestArrived.Task.WaitAsync(TimeSpan.FromSeconds(10));
+ await cancellation.CancelAsync();
+
+ await Assert.ThrowsAnyAsync(() => execution);
+ Assert.False(called);
+ }
+
+ private static async Task> ExecuteHellosAsync(SampleClient client, int count,
+ int maxDegreeOfParallelism = 4)
+ {
+ var results = new List();
+ var batch = client.CreateBatch(o => o.MaxDegreeOfParallelism = maxDegreeOfParallelism);
+ for (var i = 0; i < count; i++)
+ {
+ var name = "n" + i;
+ batch.Query(c => c.Query.Hello(name).Select().OnResult(r => results.Add(r)));
+ }
+
+ await batch.ExecuteAsync();
+ return results;
+ }
+
+ private static SampleClient CreateClient(ScriptedServer server)
+ {
+ return new SampleClient(new HttpClient(server) { BaseAddress = new Uri("https://localhost/graphql/") },
+ Options.Create(new GraphClientOptions()), new ServiceCollection().BuildServiceProvider());
+ }
+
+ [GeneratedRegex(@"(b\d+):(hello|setName)\(name:\$(\w+)")]
+ private static partial Regex RootField();
+
+ private sealed class ScriptedServer : HttpMessageHandler
+ {
+ private readonly object sync = new();
+
+ public int MaxRootFields { get; init; } = int.MaxValue;
+ public Rejection Rejection { get; init; } = Rejection.PayloadTooLarge;
+ public string? RejectName { get; init; }
+ public string? FailName { get; init; }
+ public string? FieldErrorName { get; init; }
+ public string? NullDataName { get; init; }
+ public string? EmptyName { get; init; }
+ public bool HoldRequests { get; init; }
+
+ public List RootFieldsPerRequest { get; } = [];
+ public List AcceptedAliases { get; } = [];
+ public TaskCompletionSource RequestArrived { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ protected override async Task SendAsync(HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ using var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken));
+ var query = body.RootElement.GetProperty("query").GetString()!;
+ var variables = body.RootElement.GetProperty("variables");
+ var fields = RootField().Matches(query)
+ .Select(m => (Alias: m.Groups[1].Value, Field: m.Groups[2].Value,
+ Name: variables.GetProperty(m.Groups[3].Value).GetString()))
+ .ToList();
+
+ lock (sync)
+ {
+ RootFieldsPerRequest.Add(fields.Count);
+ }
+
+ if (HoldRequests)
+ {
+ RequestArrived.TrySetResult();
+ await Task.Delay(Timeout.Infinite, cancellationToken);
+ }
+
+ if (fields.Count > MaxRootFields || fields.Any(f => f.Name == RejectName))
+ {
+ return Rejection switch
+ {
+ Rejection.PayloadTooLarge => Respond(HttpStatusCode.RequestEntityTooLarge, "Too large"),
+ Rejection.ErrorsWithStatus400 => Respond(HttpStatusCode.BadRequest,
+ """{"errors":[{"message":"Rejected"}]}"""),
+ _ => Respond(HttpStatusCode.OK, """{"errors":[{"message":"Rejected"}]}""")
+ };
+ }
+
+ if (fields.Any(f => f.Name == EmptyName))
+ {
+ return Respond(HttpStatusCode.OK, "");
+ }
+
+ if (fields.Any(f => f.Name == FailName))
+ {
+ return Respond(HttpStatusCode.InternalServerError, "Server exploded");
+ }
+
+ if (fields.FirstOrDefault(f => f.Name == NullDataName) is { Alias: not null } nullField)
+ {
+ return Respond(HttpStatusCode.OK,
+ $$"""{"data":null,"errors":[{"message":"Not null","path":["{{nullField.Alias}}"]}]}""");
+ }
+
+ lock (sync)
+ {
+ AcceptedAliases.AddRange(fields.Select(f => f.Alias));
+ }
+
+ var data = string.Join(",", fields.Select(f =>
+ f.Name == FieldErrorName ? $"\"{f.Alias}\":null" : $"\"{f.Alias}\":\"{f.Field} {f.Alias}\""));
+ var errors = fields.Where(f => f.Name == FieldErrorName)
+ .Select(f => $$"""{"message":"Bad","path":["{{f.Alias}}"]}""").ToList();
+
+ var errorsMember = errors.Count == 0 ? "" : ",\"errors\":[" + string.Join(",", errors) + "]";
+ return Respond(HttpStatusCode.OK, "{\"data\":{" + data + "}" + errorsMember + "}");
+ }
+
+ private static HttpResponseMessage Respond(HttpStatusCode status, string body)
+ {
+ return new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
+ }
+ }
+}
diff --git a/test/Linq2GraphQL.Tests/Batch/BatchTestServer.cs b/test/Linq2GraphQL.Tests/Batch/BatchTestServer.cs
new file mode 100644
index 00000000..197ff682
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Batch/BatchTestServer.cs
@@ -0,0 +1,73 @@
+using System.Collections.Concurrent;
+using System.Text.Json;
+using Linq2GraphQL.Client;
+using Linq2GraphQL.TestClient;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace Linq2GraphQL.Tests.Batch;
+
+/// The in-memory test server, with every request the client sends recorded.
+internal sealed class BatchTestServer : IDisposable
+{
+ private readonly WebApplicationFactory application = new();
+ private readonly WebApplicationFactory configuredApplication;
+ private readonly RecordingHandler recorder;
+ private readonly HttpClient httpClient;
+
+ public BatchTestServer(Action? configureServer = null)
+ {
+ configuredApplication = configureServer == null
+ ? application
+ : application.WithWebHostBuilder(builder => builder.ConfigureServices(configureServer));
+ recorder = new RecordingHandler { InnerHandler = configuredApplication.Server.CreateHandler() };
+ httpClient = new HttpClient(recorder) { BaseAddress = new Uri("https://localhost:7184/graphql/") };
+ }
+
+ /// The operations sent, schema queries left out, in the order they were sent.
+ public IReadOnlyList Operations => recorder.Operations.Where(o => !o.IsSchemaQuery).ToList();
+
+ public int SchemaQueryCount => recorder.Operations.Count(o => o.IsSchemaQuery);
+
+ public SampleClient CreateClient(bool useSafeMode = true)
+ {
+ return new SampleClient(httpClient, Options.Create(new GraphClientOptions { UseSafeMode = useSafeMode }),
+ new ServiceCollection().AddMemoryCache().BuildServiceProvider());
+ }
+
+ public void Dispose()
+ {
+ httpClient.Dispose();
+ configuredApplication.Dispose();
+ application.Dispose();
+ }
+
+ private sealed class RecordingHandler : DelegatingHandler
+ {
+ public ConcurrentQueue Operations { get; } = new();
+
+ protected override async Task SendAsync(HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ var body = await request.Content!.ReadAsStringAsync(cancellationToken);
+ Operations.Enqueue(RecordedOperation.Parse(body, request.Headers));
+ return await base.SendAsync(request, cancellationToken);
+ }
+ }
+}
+
+internal sealed record RecordedOperation(string Query, string Variables, IReadOnlyList BatchHeader)
+{
+ public bool IsSchemaQuery => Query.Contains("__schema");
+
+ public static RecordedOperation Parse(string body, System.Net.Http.Headers.HttpRequestHeaders headers)
+ {
+ using var document = JsonDocument.Parse(body);
+ var root = document.RootElement;
+ var variables = root.TryGetProperty("variables", out var value) ? value.GetRawText() : "null";
+ return new RecordedOperation(root.GetProperty("query").GetString()!, variables,
+ headers.TryGetValues("X-Batch", out var header) ? header.ToList() : []);
+ }
+}
diff --git a/test/Linq2GraphQL.Tests/Batch/BatchTests.cs b/test/Linq2GraphQL.Tests/Batch/BatchTests.cs
new file mode 100644
index 00000000..ca6c27be
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Batch/BatchTests.cs
@@ -0,0 +1,427 @@
+using Linq2GraphQL.Client;
+using Linq2GraphQL.TestClient;
+
+namespace Linq2GraphQL.Tests.Batch;
+
+public class BatchTests : IDisposable
+{
+ private readonly BatchTestServer server = new();
+ private readonly SampleClient client;
+
+ public BatchTests()
+ {
+ client = server.CreateClient();
+ }
+
+ public void Dispose()
+ {
+ server.Dispose();
+ }
+
+ [Fact]
+ public async Task TwoQueries_AreSentAsOneRequest()
+ {
+ string? jocke = null;
+ string? kalle = null;
+
+ await client.CreateBatch()
+ .Query(c => c.Query.Hello("Jocke").Select().OnResult(r => jocke = r))
+ .Query(c => c.Query.Hello("Kalle").Select().OnResult(r => kalle = r))
+ .ExecuteAsync();
+
+ Assert.Equal("Hello, Jocke!", jocke);
+ Assert.Equal("Hello, Kalle!", kalle);
+ var operation = Assert.Single(server.Operations);
+ Assert.Contains("b0:hello(name:$b0_name_1_1", operation.Query);
+ Assert.Contains("b1:hello(name:$b1_name_1_1", operation.Query);
+ }
+
+ [Fact]
+ public async Task DifferentShapes_EachGetTheirProjection()
+ {
+ List? customers = null;
+ List? orderIds = null;
+ List? animals = null;
+ int orderCount = -1;
+
+ await client.CreateBatch()
+ .Query(c => c.Query.Customers().Select().OnResult(r => customers = r))
+ .Query(c => c.Query.Orders(first: 2).Select(e => e.Nodes.Select(n => n.OrderId).ToList())
+ .OnResult(r => orderIds = r))
+ .Query(c => c.Query.Animals()
+ .Include(e => e.Nodes.Select(n => n.Spider()))
+ .Select(e => e.Nodes)
+ .OnResult(r => animals = r))
+ .Query(c => c.Query.Orders().Select(e => e.Nodes.Count()).OnResult(r => orderCount = r))
+ .ExecuteAsync();
+
+ Assert.NotEmpty(customers!);
+ Assert.Equal(2, orderIds!.Count);
+ Assert.Contains(animals!, a => a is Spider { Speed: 11 });
+ Assert.True(orderCount > 0);
+ Assert.Single(server.Operations);
+ }
+
+ [Fact]
+ public async Task QueryIf_False_AddsNothing()
+ {
+ var called = false;
+
+ await client.CreateBatch()
+ .QueryIf(c => c.Query.Hello().Select().OnResult(_ => called = true), false)
+ .QueryIf(c => c.Query.Hello().Select().OnResult(_ => called = true), () => false)
+ .ExecuteAsync();
+
+ Assert.False(called);
+ Assert.Empty(server.Operations);
+ }
+
+ [Fact]
+ public async Task QueryIf_ConditionIsEvaluatedWhenTheBatchExecutes()
+ {
+ var include = false;
+ string? result = null;
+ var batch = client.CreateBatch()
+ .QueryIf(c => c.Query.Hello().Select().OnResult(r => result = r), () => include);
+
+ include = true;
+ await batch.ExecuteAsync();
+
+ Assert.Equal("Hello, World!", result);
+ }
+
+ [Fact]
+ public async Task FactoryReturningNull_AddsNothing()
+ {
+ string? result = null;
+
+ await client.CreateBatch()
+ .Query(_ => null!)
+ .Query(c => c.Query.Hello().Select().OnResult(r => result = r))
+ .ExecuteAsync();
+
+ Assert.Equal("Hello, World!", result);
+ }
+
+ [Fact]
+ public async Task FieldError_OtherItemsGetTheirResults_ThenThrows()
+ {
+ string? hello = null;
+ var failedItemResult = "not called";
+
+ var exception = await Assert.ThrowsAsync(() => client.CreateBatch()
+ .Query(c => c.Query.Hello().Select().OnResult(r => hello = r))
+ .Query(c => c.Query.RaiseError("Boom").Select().OnResult(r => failedItemResult = r))
+ .ExecuteAsync());
+
+ Assert.Equal("Hello, World!", hello);
+ Assert.Null(failedItemResult);
+ Assert.Equal("Boom", Assert.Single(exception.Errors).Message);
+ Assert.Contains("b1:raiseError", exception.GraphQLQuery);
+ }
+
+ [Fact]
+ public async Task ExecuteWithResultAsync_ReturnsErrorsInsteadOfThrowing()
+ {
+ string? hello = null;
+
+ var result = await client.CreateBatch()
+ .Query(c => c.Query.Hello().Select().OnResult(r => hello = r))
+ .Query(c => c.Query.RaiseError("Boom").Select().OnResult(_ => { }))
+ .ExecuteWithResultAsync();
+
+ Assert.Equal("Hello, World!", hello);
+ Assert.True(result.HasErrors);
+ Assert.Empty(result.Failures);
+ var error = Assert.Single(result.Errors);
+ Assert.Equal("b1", error.Path[0].ToString());
+ Assert.Throws(result.EnsureNoErrors);
+ }
+
+ [Fact]
+ public async Task NullResult_WithoutErrors_IsDeliveredAsNull()
+ {
+ var customer = new Customer();
+
+ var result = await client.CreateBatch()
+ .Query(c => c.Query.CustomerReturnNull().Select().OnResult(r => customer = r))
+ .ExecuteWithResultAsync();
+
+ Assert.Null(customer);
+ Assert.False(result.HasErrors);
+ }
+
+ [Fact]
+ public async Task MaxItemsPerRequest_SplitsTheBatch_CallbacksKeepTheirOrder()
+ {
+ var calls = new List();
+ var batch = client.CreateBatch(o => o.MaxItemsPerRequest = 2);
+ foreach (var name in new[] { "a", "b", "c", "d", "e" })
+ {
+ batch.Query(c => c.Query.Hello(name).Select().OnResult(r => calls.Add(r)));
+ }
+
+ await batch.ExecuteAsync();
+
+ Assert.Equal(["Hello, a!", "Hello, b!", "Hello, c!", "Hello, d!", "Hello, e!"], calls);
+ Assert.Equal([2, 2, 1], server.Operations.Select(o => o.Query.Split(":hello").Length - 1).Order().Reverse());
+ }
+
+ [Fact]
+ public async Task Mutations_AreSentInOneMutationOperation()
+ {
+ var results = new List();
+
+ await client.CreateBatch()
+ .Query(c => c.Mutation.SetName("A").Select().OnResult(r => results.Add(r)))
+ .Query(c => c.Mutation.SetName("B").Select().OnResult(r => results.Add(r)))
+ .ExecuteAsync();
+
+ Assert.Equal(["New Name is: A", "New Name is: B"], results);
+ Assert.StartsWith("mutation", Assert.Single(server.Operations).Query);
+ }
+
+ [Fact]
+ public async Task QueriesAndMutations_AreSentAsSeparateOperations()
+ {
+ string? hello = null;
+ string? name = null;
+
+ await client.CreateBatch()
+ .Query(c => c.Query.Hello().Select().OnResult(r => hello = r))
+ .Query(c => c.Mutation.SetName("A").Select().OnResult(r => name = r))
+ .ExecuteAsync();
+
+ Assert.Equal("Hello, World!", hello);
+ Assert.Equal("New Name is: A", name);
+ Assert.Equal(["mutation", "query"], server.Operations.Select(o => o.Query.Split('(', '{')[0]).Order());
+ }
+
+ [Fact]
+ public async Task ParallelMode_SendsEachQueryOnItsOwn()
+ {
+ string? jocke = null;
+ string? error = "not called";
+
+ var result = await client.CreateBatch(o => o.Mode = GraphBatchMode.Parallel)
+ .Query(c => c.Query.Hello("Jocke").Select().OnResult(r => jocke = r))
+ .Query(c => c.Query.RaiseError("Boom").Select().OnResult(r => error = r))
+ .ExecuteWithResultAsync();
+
+ Assert.Equal("Hello, Jocke!", jocke);
+ Assert.Null(error);
+ Assert.Equal("Boom", Assert.Single(result.Errors).Message);
+ Assert.Equal(2, server.Operations.Count);
+ Assert.All(server.Operations, o => Assert.DoesNotContain("b0", o.Query));
+ }
+
+ [Fact]
+ public async Task BatchedQuery_CanStillBeExecutedOnItsOwn()
+ {
+ var query = client.Query.Hello("Jocke").Select();
+
+ await client.CreateBatch().Query(_ => query.OnResult(_ => { })).ExecuteAsync();
+
+ Assert.Equal("Hello, Jocke!", query.BaseResult);
+ Assert.Equal("Hello, Jocke!", await query.ExecuteAsync());
+ Assert.Contains("hello(name:$name_1_1", server.Operations[^1].Query);
+ }
+
+ [Fact]
+ public async Task SameQueryTwiceInABatch_GetsTwoResults()
+ {
+ var query = client.Query.Hello("Jocke").Select();
+ var results = new List();
+
+ await client.CreateBatch()
+ .Query(_ => query.OnResult(r => results.Add(r)))
+ .Query(_ => query.OnResult(r => results.Add(r)))
+ .ExecuteAsync();
+
+ Assert.Equal(["Hello, Jocke!", "Hello, Jocke!"], results);
+ }
+
+ [Fact]
+ public async Task SameItemTwice_Throws()
+ {
+ var item = client.Query.Hello().Select().OnResult(_ => { });
+
+ await Assert.ThrowsAsync(() => client.CreateBatch()
+ .Query(_ => item)
+ .Query(_ => item)
+ .ExecuteAsync());
+ }
+
+ [Fact]
+ public async Task QueryAddedAfterAMutation_IsSentAfterIt()
+ {
+ await client.CreateBatch()
+ .Query(c => c.Query.Hello("a").Select().OnResult(_ => { }))
+ .Query(c => c.Mutation.SetName("m").Select().OnResult(_ => { }))
+ .Query(c => c.Query.Hello("b").Select().OnResult(_ => { }))
+ .ExecuteAsync();
+
+ Assert.Equal(["query", "mutation", "query"], server.Operations.Select(o => o.Query.Split('(', '{')[0]));
+ Assert.Contains("b2:hello", server.Operations[2].Query);
+ }
+
+ [Fact]
+ public async Task Aliases_CountSkippedQueries()
+ {
+ var result = await client.CreateBatch()
+ .QueryIf(c => c.Query.Hello().Select().OnResult(_ => { }), false)
+ .Query(c => c.Query.RaiseError("Boom").Select().OnResult(_ => { }))
+ .ExecuteWithResultAsync();
+
+ Assert.Equal("b1", Assert.Single(result.Errors).Path[0].ToString());
+ }
+
+ [Fact]
+ public async Task QueryInitializedBeforeBatching_IsRenamedForTheBatch()
+ {
+ string? result = null;
+ var query = client.Query.Hello("Jocke").Select();
+ await query.GetRequestAsync();
+
+ await client.CreateBatch()
+ .Query(_ => query.OnResult(r => result = r))
+ .ExecuteAsync();
+
+ Assert.Equal("Hello, Jocke!", result);
+ Assert.Contains("$b0_name_1_1", Assert.Single(server.Operations).Query);
+ }
+
+ [Fact]
+ public async Task ExecutingABatchTwice_Throws()
+ {
+ var batch = client.CreateBatch().Query(c => c.Query.Hello().Select().OnResult(_ => { }));
+ await batch.ExecuteAsync();
+
+ await Assert.ThrowsAsync(() => batch.ExecuteAsync());
+ }
+
+ [Fact]
+ public async Task DerivedClient_RequestHooksRunOnTheBatchRequest()
+ {
+ var userClient = client.WithRequestConfiguration((request, _) =>
+ {
+ request.Headers.Add("X-Batch", "user");
+ return ValueTask.CompletedTask;
+ });
+
+ await userClient.CreateBatch()
+ .Query(c => c.Query.Hello().Select().OnResult(_ => { }))
+ .ExecuteAsync();
+
+ Assert.Equal(["user"], Assert.Single(server.Operations).BatchHeader);
+ }
+
+ [Fact]
+ public async Task ItemsFromTwoClients_AreSentWithTheirOwnHooks()
+ {
+ var userClient = client.WithRequestConfiguration((request, _) =>
+ {
+ request.Headers.Add("X-Batch", "user");
+ return ValueTask.CompletedTask;
+ });
+
+ await client.CreateBatch()
+ .Query(c => c.Query.Hello().Select().OnResult(_ => { }))
+ .Query(_ => userClient.Query.Hello().Select().OnResult(_ => { }))
+ .ExecuteAsync();
+
+ Assert.Equal(2, server.Operations.Count);
+ Assert.Single(server.Operations, o => o.BatchHeader.SequenceEqual(["user"]));
+ }
+
+ [Fact]
+ public async Task CancelledToken_InvokesNoCallbacks()
+ {
+ var called = false;
+ using var cancellation = new CancellationTokenSource();
+ await cancellation.CancelAsync();
+
+ await Assert.ThrowsAnyAsync(() => client.CreateBatch()
+ .Query(c => c.Query.Hello().Select().OnResult(_ => called = true))
+ .ExecuteAsync(cancellation.Token));
+
+ Assert.False(called);
+ }
+
+ [Fact]
+ public async Task OnResultAsync_IsAwaitedBeforeTheNextCallback()
+ {
+ var calls = new List();
+
+ await client.CreateBatch()
+ .Query(c => c.Query.Hello("a").Select().OnResultAsync(async r =>
+ {
+ await Task.Delay(20);
+ calls.Add(r);
+ }))
+ .Query(c => c.Query.Hello("b").Select().OnResult(r => calls.Add(r)))
+ .ExecuteAsync();
+
+ Assert.Equal(["Hello, a!", "Hello, b!"], calls);
+ }
+
+ [Fact]
+ public async Task ACallbackThatThrows_StopsTheBatch()
+ {
+ var secondCalled = false;
+
+ await Assert.ThrowsAsync(() => client.CreateBatch()
+ .Query(c => c.Query.Hello().Select().OnResult(_ => throw new FormatException()))
+ .Query(c => c.Query.Hello().Select().OnResult(_ => secondCalled = true))
+ .ExecuteAsync());
+
+ Assert.False(secondCalled);
+ }
+
+ [Fact]
+ public async Task ClientInterface_CanCreateABatch()
+ {
+ ISampleClient sampleClient = client;
+ string? result = null;
+
+ await GraphBatch.Create(sampleClient)
+ .Query(c => c.Query.Hello().Select().OnResult(r => result = r))
+ .ExecuteAsync();
+
+ Assert.Equal("Hello, World!", result);
+ }
+
+ [Fact]
+ public async Task SafeMode_FetchesTheSchemaOnceForTheWholeBatch()
+ {
+ var batch = client.CreateBatch();
+ for (var i = 0; i < 5; i++)
+ {
+ batch.Query(c => c.Query.Orders(first: 1).Select(e => e.Nodes).OnResult(_ => { }));
+ }
+
+ await batch.ExecuteAsync();
+
+ Assert.Equal(1, server.SchemaQueryCount);
+ }
+
+ [Fact]
+ public async Task ManyBatchesInParallel_OnOneClient_AllGetTheirResults()
+ {
+ var results = new string?[20];
+
+ await Task.WhenAll(Enumerable.Range(0, 20).Select(i => client.CreateBatch()
+ .Query(c => c.Query.Hello("n" + i).Select().OnResult(r => results[i] = r))
+ .Query(c => c.Query.Customers().Select(e => e.Count).OnResult(_ => { }))
+ .ExecuteAsync()));
+
+ Assert.Equal(Enumerable.Range(0, 20).Select(i => $"Hello, n{i}!"), results);
+ }
+
+ [Fact]
+ public void InvalidOptions_AreRejected()
+ {
+ Assert.Throws(() => client.CreateBatch(o => o.MaxItemsPerRequest = 0));
+ Assert.Throws(() => client.CreateBatch(o => o.MaxDegreeOfParallelism = 0));
+ }
+}
diff --git a/test/Linq2GraphQL.Tests/Batch/Snapshots/Batch.Mutations.txt b/test/Linq2GraphQL.Tests/Batch/Snapshots/Batch.Mutations.txt
new file mode 100644
index 00000000..58e27271
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Batch/Snapshots/Batch.Mutations.txt
@@ -0,0 +1,7 @@
+mutation($b0_name_1_1: String $b1_name_1_1: String ){
+b0:setName(name:$b0_name_1_1 )
+b1:setName(name:$b1_name_1_1 )
+
+}
+
+{"b0_name_1_1":"A","b1_name_1_1":"B"}
\ No newline at end of file
diff --git a/test/Linq2GraphQL.Tests/Batch/Snapshots/Batch.Queries_WithArgumentsAtSeveralLevels.txt b/test/Linq2GraphQL.Tests/Batch/Snapshots/Batch.Queries_WithArgumentsAtSeveralLevels.txt
new file mode 100644
index 00000000..6fbe3712
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Batch/Snapshots/Batch.Queries_WithArgumentsAtSeveralLevels.txt
@@ -0,0 +1,35 @@
+query($b0_name_1_1: String $b2_first_1_1: Int $b2_name_3_1: String! $b2_first_3_1: Int! $b2_addressType_3_2: AddressType! $b3_name_3_1: String! $b3_first_3_1: Int! $b3_name_3_2: String! $b3_first_3_2: Int! ){
+b0:hello(name:$b0_name_1_1 )
+b1:hello
+b2:orders(first:$b2_first_1_1 )
+{
+nodes
+ {
+ orderHello_5ff28576c49a8f8f:orderHello(name:$b2_name_3_1 first:$b2_first_3_1 )
+orderAddress_36cad6e5eedd8675:orderAddress(addressType:$b2_addressType_3_2 )
+ {
+ name
+ street
+ postalCode
+ createdDate
+ }
+ orderId
+ orderDate
+ orderDateTime
+ orderDay
+ entryTime
+ grade
+ }
+}
+b3:orders
+{
+nodes
+ {
+ orderHello_afcc6be86742ac9d:orderHello(name:$b3_name_3_1 first:$b3_first_3_1 )
+ orderHello_7634295004c92665:orderHello(name:$b3_name_3_2 first:$b3_first_3_2 )
+ }
+}
+
+}
+
+{"b0_name_1_1":"Jocke","b2_first_1_1":1,"b2_name_3_1":"Jocke","b2_first_3_1":2,"b2_addressType_3_2":"INVOICE","b3_name_3_1":"A","b3_first_3_1":1,"b3_name_3_2":"B","b3_first_3_2":2}
\ No newline at end of file
diff --git a/test/Linq2GraphQL.Tests/RequestSnapshotTests.cs b/test/Linq2GraphQL.Tests/RequestSnapshotTests.cs
new file mode 100644
index 00000000..4f7aefe7
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/RequestSnapshotTests.cs
@@ -0,0 +1,126 @@
+using Linq2GraphQL.Client;
+using Linq2GraphQL.TestClient;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace Linq2GraphQL.Tests;
+
+///
+/// Golden copies of the request (query text and variables) a single query sends. Any change to them changes what
+/// existing clients send to their servers, so a diff here must be deliberate.
+///
+public class RequestSnapshotTests : IClassFixture, IClassFixture
+{
+ private static readonly Guid FixedId = Guid.Parse("0190a4c2-7b3e-7c1a-9d2f-3e4b5a6c7d8e");
+
+ private readonly SampleClient safeModeClient;
+ private readonly SampleClient client;
+ private readonly TestClientNullable.SampleNullableClient nullableClient;
+
+ public RequestSnapshotTests(SampleClientFixture safeModeFixture, SampleNullableClientFixture nullableFixture)
+ {
+ safeModeClient = safeModeFixture.sampleClient;
+ nullableClient = nullableFixture.sampleClient;
+ client = new SampleClient(new HttpClient { BaseAddress = new Uri("https://localhost/graphql/") },
+ Options.Create(new GraphClientOptions()), new ServiceCollection().BuildServiceProvider());
+ }
+
+ [Fact]
+ public Task Hello_WithoutArguments() =>
+ MatchesSnapshot(safeModeClient.Query.Hello().Select(), "Hello_WithoutArguments");
+
+ [Fact]
+ public Task Hello_WithArgument() =>
+ MatchesSnapshot(safeModeClient.Query.Hello("Magnus").Select(), "Hello_WithArgument");
+
+ [Fact]
+ public Task Orders_TopLevelArgument() =>
+ MatchesSnapshot(safeModeClient.Query.Orders(1).Select(e => e.Nodes), "Orders_TopLevelArgument");
+
+ [Fact]
+ public Task Orders_ArgumentOnNestedField() =>
+ MatchesSnapshot(safeModeClient.Query.Orders(1)
+ .Include(e => e.Nodes.Select(n => n.OrderAddress(AddressType.Invoice)))
+ .Select(e => e.Nodes), "Orders_ArgumentOnNestedField");
+
+ [Fact]
+ public Task Orders_SameArgumentNameAtTwoLevels() =>
+ MatchesSnapshot(safeModeClient.Query.Orders(1)
+ .Include(e => e.Nodes.Select(n => n.OrderHello("Jocke", 2)))
+ .Include(e => e.Nodes.Select(n => n.OrderAddress(AddressType.Invoice)))
+ .Select(e => e.Nodes), "Orders_SameArgumentNameAtTwoLevels");
+
+ [Fact]
+ public Task Orders_SameFieldWithDifferentArguments() =>
+ MatchesSnapshot(safeModeClient.Query.Orders()
+ .Select(e => e.Nodes.Select(n => new { Jocke = n.OrderHello("Jocke", 22), Kalle = n.OrderHello("Kalle", 11) })),
+ "Orders_SameFieldWithDifferentArguments");
+
+ [Fact]
+ public Task Orders_IncludeMultipleLevels() =>
+ MatchesSnapshot(safeModeClient.Query.Orders(1)
+ .Include(e => e.Nodes.Select(n => n.Customer))
+ .Include(e => e.Nodes.Select(n => n.Customer.Orders))
+ .Select(e => e.Nodes), "Orders_IncludeMultipleLevels");
+
+ [Fact]
+ public Task Orders_ProjectionWithLinqOperators() =>
+ MatchesSnapshot(safeModeClient.Query.Orders()
+ .Select(e => e.Nodes
+ .Where(n => n.OrderId != Guid.Empty)
+ .Select(n => new { n.OrderId, n.Customer.CustomerName })), "Orders_ProjectionWithLinqOperators");
+
+ [Fact]
+ public Task Orders_FilterAndSortInputs() =>
+ MatchesSnapshot(safeModeClient.Query.Orders(first: 2, after: "cursor",
+ where: new OrderFilterInput
+ {
+ Customer = new CustomerFilterInput { CustomerName = new StringOperationFilterInput { StartsWith = "A" } }
+ },
+ order: [new OrderSortInput { OrderDate = SortEnumType.Desc }])
+ .Select(e => e.Nodes.Select(n => new { n.OrderId, n.OrderDate })), "Orders_FilterAndSortInputs");
+
+ [Fact]
+ public Task Animals_InterfaceSubtypes() =>
+ MatchesSnapshot(safeModeClient.Query.Animals()
+ .Include(e => e.Nodes.Select(n => n.Pig()))
+ .Include(e => e.Nodes.Select(n => n.Spider()))
+ .Select(e => e.Nodes), "Animals_InterfaceSubtypes");
+
+ [Fact]
+ public Task Customers_SelectMany() =>
+ MatchesSnapshot(safeModeClient.Query.Customers()
+ .Select(e => e.SelectMany(c => c.Orders, (c, o) => new { c.CustomerName, o.OrderId })),
+ "Customers_SelectMany");
+
+ [Fact]
+ public Task Mutation_ScalarArgument() =>
+ MatchesSnapshot(safeModeClient.Mutation.SetName("Magnus").Select(), "Mutation_ScalarArgument");
+
+ [Fact]
+ public Task Mutation_InputObject() =>
+ MatchesSnapshot(safeModeClient.Mutation.AddCustomer(new CustomerInput
+ {
+ CustomerId = FixedId, CustomerName = "New Customer", Orders = [], Status = CustomerStatus.Active
+ })
+ .Select(e => e.CustomerId), "Mutation_InputObject");
+
+ [Fact]
+ public Task SafeModeOff_IncludesEveryPrimitive() =>
+ MatchesSnapshot(client.Query.Orders(1)
+ .Include(e => e.Nodes.Select(n => n.Customer))
+ .Select(e => e.Nodes), "SafeModeOff_IncludesEveryPrimitive");
+
+ [Fact]
+ public Task NullableClient_ListOfNullables() =>
+ MatchesSnapshot(nullableClient.Query.CustomerListAllNullable().Select(), "NullableClient_ListOfNullables");
+
+ [Fact]
+ public Task NullableClient_ListInList() =>
+ MatchesSnapshot(nullableClient.Query.CustomerListInList().Select(), "NullableClient_ListInList");
+
+ private static async Task MatchesSnapshot(GraphQueryExecute query, string name)
+ {
+ Snapshot.Match(await query.GetRequestAsJsonAsync(), "Request." + name);
+ }
+}
diff --git a/test/Linq2GraphQL.Tests/Snapshots/Request.Animals_InterfaceSubtypes.txt b/test/Linq2GraphQL.Tests/Snapshots/Request.Animals_InterfaceSubtypes.txt
new file mode 100644
index 00000000..70d2e575
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Snapshots/Request.Animals_InterfaceSubtypes.txt
@@ -0,0 +1,30 @@
+query{
+animals
+{
+nodes
+ {
+... on Pig
+ {
+ spices
+ name
+ numberOfLegs
+ speed
+ __typename
+ }
+... on Spider
+ {
+ poisonous
+ name
+ numberOfLegs
+ speed
+ __typename
+ }
+ name
+ numberOfLegs
+ __typename
+ }
+}
+
+}
+
+{}
\ No newline at end of file
diff --git a/test/Linq2GraphQL.Tests/Snapshots/Request.Customers_SelectMany.txt b/test/Linq2GraphQL.Tests/Snapshots/Request.Customers_SelectMany.txt
new file mode 100644
index 00000000..ab98025a
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Snapshots/Request.Customers_SelectMany.txt
@@ -0,0 +1,13 @@
+query{
+customers
+{
+orders
+ {
+ orderId
+ }
+ customerName
+}
+
+}
+
+{}
\ No newline at end of file
diff --git a/test/Linq2GraphQL.Tests/Snapshots/Request.Hello_WithArgument.txt b/test/Linq2GraphQL.Tests/Snapshots/Request.Hello_WithArgument.txt
new file mode 100644
index 00000000..93c9649e
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Snapshots/Request.Hello_WithArgument.txt
@@ -0,0 +1,6 @@
+query($name_1_1: String ){
+hello(name:$name_1_1 )
+
+}
+
+{"name_1_1":"Magnus"}
\ No newline at end of file
diff --git a/test/Linq2GraphQL.Tests/Snapshots/Request.Hello_WithoutArguments.txt b/test/Linq2GraphQL.Tests/Snapshots/Request.Hello_WithoutArguments.txt
new file mode 100644
index 00000000..1d89257a
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Snapshots/Request.Hello_WithoutArguments.txt
@@ -0,0 +1,6 @@
+query{
+hello
+
+}
+
+{}
\ No newline at end of file
diff --git a/test/Linq2GraphQL.Tests/Snapshots/Request.Mutation_InputObject.txt b/test/Linq2GraphQL.Tests/Snapshots/Request.Mutation_InputObject.txt
new file mode 100644
index 00000000..c4448ec0
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Snapshots/Request.Mutation_InputObject.txt
@@ -0,0 +1,9 @@
+mutation($customer_1_1: CustomerInput ){
+addCustomer(customer:$customer_1_1 )
+{
+ customerId
+}
+
+}
+
+{"customer_1_1":{"status":"ACTIVE","customerName":"New Customer","orders":[],"customerId":"0190a4c2-7b3e-7c1a-9d2f-3e4b5a6c7d8e"}}
\ No newline at end of file
diff --git a/test/Linq2GraphQL.Tests/Snapshots/Request.Mutation_ScalarArgument.txt b/test/Linq2GraphQL.Tests/Snapshots/Request.Mutation_ScalarArgument.txt
new file mode 100644
index 00000000..4cda679e
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Snapshots/Request.Mutation_ScalarArgument.txt
@@ -0,0 +1,6 @@
+mutation($name_1_1: String ){
+setName(name:$name_1_1 )
+
+}
+
+{"name_1_1":"Magnus"}
\ No newline at end of file
diff --git a/test/Linq2GraphQL.Tests/Snapshots/Request.NullableClient_ListInList.txt b/test/Linq2GraphQL.Tests/Snapshots/Request.NullableClient_ListInList.txt
new file mode 100644
index 00000000..a2d7d724
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Snapshots/Request.NullableClient_ListInList.txt
@@ -0,0 +1,12 @@
+query{
+customerListInList
+{
+ customerId
+ customerName
+ status
+ created
+}
+
+}
+
+{}
\ No newline at end of file
diff --git a/test/Linq2GraphQL.Tests/Snapshots/Request.NullableClient_ListOfNullables.txt b/test/Linq2GraphQL.Tests/Snapshots/Request.NullableClient_ListOfNullables.txt
new file mode 100644
index 00000000..f46b510c
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Snapshots/Request.NullableClient_ListOfNullables.txt
@@ -0,0 +1,12 @@
+query{
+customerListAllNullable
+{
+ customerId
+ customerName
+ status
+ created
+}
+
+}
+
+{}
\ No newline at end of file
diff --git a/test/Linq2GraphQL.Tests/Snapshots/Request.Orders_ArgumentOnNestedField.txt b/test/Linq2GraphQL.Tests/Snapshots/Request.Orders_ArgumentOnNestedField.txt
new file mode 100644
index 00000000..b6145a64
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Snapshots/Request.Orders_ArgumentOnNestedField.txt
@@ -0,0 +1,24 @@
+query($first_1_1: Int $addressType_3_1: AddressType! ){
+orders(first:$first_1_1 )
+{
+nodes
+ {
+orderAddress_36cad6e5eedd8675:orderAddress(addressType:$addressType_3_1 )
+ {
+ name
+ street
+ postalCode
+ createdDate
+ }
+ orderId
+ orderDate
+ orderDateTime
+ orderDay
+ entryTime
+ grade
+ }
+}
+
+}
+
+{"first_1_1":1,"addressType_3_1":"INVOICE"}
\ No newline at end of file
diff --git a/test/Linq2GraphQL.Tests/Snapshots/Request.Orders_FilterAndSortInputs.txt b/test/Linq2GraphQL.Tests/Snapshots/Request.Orders_FilterAndSortInputs.txt
new file mode 100644
index 00000000..7fbae2dc
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Snapshots/Request.Orders_FilterAndSortInputs.txt
@@ -0,0 +1,13 @@
+query($first_1_1: Int $after_1_1: String $where_1_1: OrderFilterInput $order_1_1: [OrderSortInput!] ){
+orders(first:$first_1_1 after:$after_1_1 where:$where_1_1 order:$order_1_1 )
+{
+nodes
+ {
+ orderId
+ orderDate
+ }
+}
+
+}
+
+{"first_1_1":2,"after_1_1":"cursor","where_1_1":{"customer":{"customerName":{"startsWith":"A"}}},"order_1_1":[{"orderDate":"DESC"}]}
\ No newline at end of file
diff --git a/test/Linq2GraphQL.Tests/Snapshots/Request.Orders_IncludeMultipleLevels.txt b/test/Linq2GraphQL.Tests/Snapshots/Request.Orders_IncludeMultipleLevels.txt
new file mode 100644
index 00000000..d6cf1d32
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Snapshots/Request.Orders_IncludeMultipleLevels.txt
@@ -0,0 +1,33 @@
+query($first_1_1: Int ){
+orders(first:$first_1_1 )
+{
+nodes
+ {
+customer
+ {
+orders
+ {
+ orderId
+ orderDate
+ orderDateTime
+ orderDay
+ entryTime
+ grade
+ }
+ customerId
+ customerName
+ status
+ created
+ }
+ orderId
+ orderDate
+ orderDateTime
+ orderDay
+ entryTime
+ grade
+ }
+}
+
+}
+
+{"first_1_1":1}
\ No newline at end of file
diff --git a/test/Linq2GraphQL.Tests/Snapshots/Request.Orders_ProjectionWithLinqOperators.txt b/test/Linq2GraphQL.Tests/Snapshots/Request.Orders_ProjectionWithLinqOperators.txt
new file mode 100644
index 00000000..f67096d8
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Snapshots/Request.Orders_ProjectionWithLinqOperators.txt
@@ -0,0 +1,16 @@
+query{
+orders
+{
+nodes
+ {
+ orderId
+customer
+ {
+ customerName
+ }
+ }
+}
+
+}
+
+{}
\ No newline at end of file
diff --git a/test/Linq2GraphQL.Tests/Snapshots/Request.Orders_SameArgumentNameAtTwoLevels.txt b/test/Linq2GraphQL.Tests/Snapshots/Request.Orders_SameArgumentNameAtTwoLevels.txt
new file mode 100644
index 00000000..6d6bfb07
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Snapshots/Request.Orders_SameArgumentNameAtTwoLevels.txt
@@ -0,0 +1,25 @@
+query($first_1_1: Int $name_3_1: String! $first_3_1: Int! $addressType_3_2: AddressType! ){
+orders(first:$first_1_1 )
+{
+nodes
+ {
+ orderHello_5ff28576c49a8f8f:orderHello(name:$name_3_1 first:$first_3_1 )
+orderAddress_36cad6e5eedd8675:orderAddress(addressType:$addressType_3_2 )
+ {
+ name
+ street
+ postalCode
+ createdDate
+ }
+ orderId
+ orderDate
+ orderDateTime
+ orderDay
+ entryTime
+ grade
+ }
+}
+
+}
+
+{"first_1_1":1,"name_3_1":"Jocke","first_3_1":2,"addressType_3_2":"INVOICE"}
\ No newline at end of file
diff --git a/test/Linq2GraphQL.Tests/Snapshots/Request.Orders_SameFieldWithDifferentArguments.txt b/test/Linq2GraphQL.Tests/Snapshots/Request.Orders_SameFieldWithDifferentArguments.txt
new file mode 100644
index 00000000..3f666aa9
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Snapshots/Request.Orders_SameFieldWithDifferentArguments.txt
@@ -0,0 +1,13 @@
+query($name_3_1: String! $first_3_1: Int! $name_3_2: String! $first_3_2: Int! ){
+orders
+{
+nodes
+ {
+ orderHello_99bd189de534313d:orderHello(name:$name_3_1 first:$first_3_1 )
+ orderHello_f3e72219bd8446aa:orderHello(name:$name_3_2 first:$first_3_2 )
+ }
+}
+
+}
+
+{"name_3_1":"Jocke","first_3_1":22,"name_3_2":"Kalle","first_3_2":11}
\ No newline at end of file
diff --git a/test/Linq2GraphQL.Tests/Snapshots/Request.Orders_TopLevelArgument.txt b/test/Linq2GraphQL.Tests/Snapshots/Request.Orders_TopLevelArgument.txt
new file mode 100644
index 00000000..ced5d998
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Snapshots/Request.Orders_TopLevelArgument.txt
@@ -0,0 +1,17 @@
+query($first_1_1: Int ){
+orders(first:$first_1_1 )
+{
+nodes
+ {
+ orderId
+ orderDate
+ orderDateTime
+ orderDay
+ entryTime
+ grade
+ }
+}
+
+}
+
+{"first_1_1":1}
\ No newline at end of file
diff --git a/test/Linq2GraphQL.Tests/Snapshots/Request.SafeModeOff_IncludesEveryPrimitive.txt b/test/Linq2GraphQL.Tests/Snapshots/Request.SafeModeOff_IncludesEveryPrimitive.txt
new file mode 100644
index 00000000..25f0f96d
--- /dev/null
+++ b/test/Linq2GraphQL.Tests/Snapshots/Request.SafeModeOff_IncludesEveryPrimitive.txt
@@ -0,0 +1,24 @@
+query($first_1_1: Int ){
+orders(first:$first_1_1 )
+{
+nodes
+ {
+customer
+ {
+ customerId
+ customerName
+ status
+ created
+ }
+ orderId
+ orderDate
+ orderDateTime
+ orderDay
+ entryTime
+ grade
+ }
+}
+
+}
+
+{"first_1_1":1}
\ No newline at end of file