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: +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FailureCallbackExecuteAsync throwsExecuteWithResultAsync
A field returns an errorRuns, with the data that came back (often null)GraphQueryExecutionExceptionErrors
+ 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 requestGraphQueryExecutionExceptionErrors
The HTTP request fails or times outDoesn't run for any query in that requestGraphQueryRequestException or TaskCanceledExceptionFailures
The batch is cancelledNo callback runsOperationCanceledExceptionThrows 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:

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
OptionDefaultWhat it does
ModeSingleRequest + 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. +
MaxItemsPerRequest100The most queries merged into one request.
MaxDegreeOfParallelism4The most requests in flight at once. Mutations are always sent one at a time.
SplitOnRejectiontrueSplit 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