From 4d468b9bff1cca477ca6c5fddd85bad7345303b4 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 11 Jul 2022 19:50:03 +0200 Subject: [PATCH] Add support for defer if argument (#5230) --- .../HttpPostMiddlewareTests.cs | 62 +++++++++++++ ...Correct_With_Defer_If_Condition_False.snap | 1 + ..._Correct_With_Defer_If_Condition_True.snap | 9 ++ .../DirectiveCollectionExtensions.cs | 29 ++++++- .../Core/src/Execution/Processing/Fragment.cs | 6 +- .../Execution/Processing/IncludeCondition.cs | 11 ++- .../Execution/Processing/OperationCompiler.cs | 77 ++++++++++++++++- .../src/Execution/Processing/Selection.cs | 12 ++- .../Processing/Tasks/ResolverTask.Execute.cs | 4 +- .../Types/Execution/Processing/ISelection.cs | 14 ++- .../src/Types/Types/Contracts/IObjectField.cs | 5 -- .../Core/src/Types/Types/ObjectField.cs | 14 --- .../Core/test/Execution.Tests/DeferTests.cs | 86 +++++++++++++++++-- ...ests.Do_Defer_If_Variable_Set_To_True.snap | 29 +++++++ .../DeferTests.Do_Not_Defer.snap | 43 ++++------ ...Do_Not_Defer_If_Variable_Set_To_False.snap | 20 +++++ 16 files changed, 348 insertions(+), 74 deletions(-) create mode 100644 src/HotChocolate/AspNetCore/test/AspNetCore.Tests/__snapshots__/HttpPostMiddlewareTests.Ensure_JSON_Format_Is_Correct_With_Defer_If_Condition_False.snap create mode 100644 src/HotChocolate/AspNetCore/test/AspNetCore.Tests/__snapshots__/HttpPostMiddlewareTests.Ensure_Multipart_Format_Is_Correct_With_Defer_If_Condition_True.snap create mode 100644 src/HotChocolate/Core/test/Execution.Tests/__snapshots__/DeferTests.Do_Defer_If_Variable_Set_To_True.snap create mode 100644 src/HotChocolate/Core/test/Execution.Tests/__snapshots__/DeferTests.Do_Not_Defer_If_Variable_Set_To_False.snap diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpPostMiddlewareTests.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpPostMiddlewareTests.cs index 03b6b58c806..1ed5d591547 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpPostMiddlewareTests.cs +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/HttpPostMiddlewareTests.cs @@ -350,6 +350,68 @@ public async Task Ensure_Multipart_Format_Is_Correct_With_Defer() result.Content.MatchSnapshot(); } + [Fact] + public async Task Ensure_Multipart_Format_Is_Correct_With_Defer_If_Condition_True() + { + // arrange + var server = CreateStarWarsServer(); + + // act + var result = + await server.PostRawAsync(new ClientQueryRequest + { + Query = @" + query ($if: Boolean!){ + hero(episode: NEW_HOPE) + { + name + ... on Droid @defer(label: ""my_id"", if: $if) + { + id + } + } + }", + Variables = new Dictionary + { + ["if"] = true + } + }); + + // assert + result.Content.MatchSnapshot(); + } + + [Fact] + public async Task Ensure_JSON_Format_Is_Correct_With_Defer_If_Condition_False() + { + // arrange + var server = CreateStarWarsServer(); + + // act + var result = + await server.PostRawAsync(new ClientQueryRequest + { + Query = @" + query ($if: Boolean!){ + hero(episode: NEW_HOPE) + { + name + ... on Droid @defer(label: ""my_id"", if: $if) + { + id + } + } + }", + Variables = new Dictionary + { + ["if"] = false + } + }); + + // assert + result.Content.MatchSnapshot(); + } + [Fact] public async Task Ensure_Multipart_Format_Is_Correct_With_Stream() { diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/__snapshots__/HttpPostMiddlewareTests.Ensure_JSON_Format_Is_Correct_With_Defer_If_Condition_False.snap b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/__snapshots__/HttpPostMiddlewareTests.Ensure_JSON_Format_Is_Correct_With_Defer_If_Condition_False.snap new file mode 100644 index 00000000000..bd7ab53765f --- /dev/null +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/__snapshots__/HttpPostMiddlewareTests.Ensure_JSON_Format_Is_Correct_With_Defer_If_Condition_False.snap @@ -0,0 +1 @@ +{"data":{"hero":{"name":"R2-D2","id":"2001"}}} diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/__snapshots__/HttpPostMiddlewareTests.Ensure_Multipart_Format_Is_Correct_With_Defer_If_Condition_True.snap b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/__snapshots__/HttpPostMiddlewareTests.Ensure_Multipart_Format_Is_Correct_With_Defer_If_Condition_True.snap new file mode 100644 index 00000000000..2cefad762d8 --- /dev/null +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/__snapshots__/HttpPostMiddlewareTests.Ensure_Multipart_Format_Is_Correct_With_Defer_If_Condition_True.snap @@ -0,0 +1,9 @@ +--- +Content-Type: application/json; charset=utf-8 + +{"data":{"hero":{"name":"R2-D2"}},"hasNext":true} +--- +Content-Type: application/json; charset=utf-8 + +{"label":"my_id","path":["hero"],"data":{"id":"2001"},"hasNext":false} +----- diff --git a/src/HotChocolate/Core/src/Execution/Processing/DirectiveCollectionExtensions.cs b/src/HotChocolate/Core/src/Execution/Processing/DirectiveCollectionExtensions.cs index c93fbebf76a..62b7154123c 100644 --- a/src/HotChocolate/Core/src/Execution/Processing/DirectiveCollectionExtensions.cs +++ b/src/HotChocolate/Core/src/Execution/Processing/DirectiveCollectionExtensions.cs @@ -26,8 +26,15 @@ internal static class DirectiveCollectionExtensions public static bool IsDeferrable(this FragmentSpreadNode fragmentSpreadNode) => fragmentSpreadNode.Directives.IsDeferrable(); - public static bool IsDeferrable(this IReadOnlyList directives) => - directives.GetDeferDirective() is not null; + public static bool IsDeferrable(this IReadOnlyList directives) + { + var directive = directives.GetDeferDirective(); + var ifValue = directive?.GetIfArgumentValueOrDefault(); + + // a fragment is not deferrable if we do not find a defer directive or + // if the `if` of the defer directive is a bool literal with a false value. + return directive is not null && ifValue is not BooleanValueNode { Value: false }; + } public static bool IsStreamable(this FieldNode field) => field.Directives.GetStreamDirective() is not null; @@ -105,8 +112,7 @@ VariableNode variable this IReadOnlyList directives, IVariableValueCollection variables) { - var directiveNode = - GetDirective(directives, WellKnownDirectives.Stream); + var directiveNode = GetDirective(directives, WellKnownDirectives.Stream); if (directiveNode is not null) { @@ -156,6 +162,21 @@ VariableNode variable return null; } + internal static IValueNode? GetIfArgumentValueOrDefault(this DirectiveNode directive) + { + for (var i = 0; i < directive.Arguments.Count; i++) + { + var argument = directive.Arguments[i]; + + if (argument.Name.Value.EqualsOrdinal(WellKnownDirectives.IfArgument)) + { + return argument.Value; + } + } + + return null; + } + internal static DirectiveNode? GetDeferDirective( this IReadOnlyList directives) => GetDirective(directives, WellKnownDirectives.Defer); diff --git a/src/HotChocolate/Core/src/Execution/Processing/Fragment.cs b/src/HotChocolate/Core/src/Execution/Processing/Fragment.cs index 76ff1ea812e..568b0ebe554 100644 --- a/src/HotChocolate/Core/src/Execution/Processing/Fragment.cs +++ b/src/HotChocolate/Core/src/Execution/Processing/Fragment.cs @@ -7,6 +7,7 @@ namespace HotChocolate.Execution.Processing; internal sealed class Fragment : IFragment { private readonly long _includeCondition; + private readonly long _deferIfCondition; public Fragment( int id, @@ -16,6 +17,7 @@ internal sealed class Fragment : IFragment int selectionSetId, ISelectionSet selectionSet, long includeCondition, + long deferIfCondition, bool isInternal = false) { Id = id; @@ -25,6 +27,7 @@ internal sealed class Fragment : IFragment SelectionSetId = selectionSetId; SelectionSet = selectionSet; _includeCondition = includeCondition; + _deferIfCondition = deferIfCondition; IsInternal = isInternal; } @@ -42,12 +45,13 @@ internal sealed class Fragment : IFragment public bool IsInternal { get; } - public bool IsConditional => _includeCondition is not 0; + public bool IsConditional => _includeCondition is not 0 || _deferIfCondition is not 0; public string? GetLabel(IVariableValueCollection variables) => Directives.GetDeferDirective(variables)?.Label; public bool IsIncluded(long includeFlags, bool allowInternals = false) => (includeFlags & _includeCondition) == _includeCondition && + (_deferIfCondition is 0 || (includeFlags & _deferIfCondition) != _deferIfCondition) && (!IsInternal || allowInternals); } diff --git a/src/HotChocolate/Core/src/Execution/Processing/IncludeCondition.cs b/src/HotChocolate/Core/src/Execution/Processing/IncludeCondition.cs index 0dac9f5107a..c933d47a26e 100644 --- a/src/HotChocolate/Core/src/Execution/Processing/IncludeCondition.cs +++ b/src/HotChocolate/Core/src/Execution/Processing/IncludeCondition.cs @@ -9,7 +9,7 @@ namespace HotChocolate.Execution.Processing; /// public readonly struct IncludeCondition : IEquatable { - private IncludeCondition(IValueNode skip, IValueNode include) + internal IncludeCondition(IValueNode skip, IValueNode include) { Skip = skip; Include = include; @@ -29,9 +29,7 @@ private IncludeCondition(IValueNode skip, IValueNode include) /// If and are null then /// there is no valid include condition. /// - public bool IsDefault - => ReferenceEquals(Skip, null) && - ReferenceEquals(Include, null); + public bool IsDefault => Skip is null && Include is null; /// /// Specifies if selections with this include condition are included with the @@ -127,6 +125,11 @@ public override int GetHashCode() /// public static IncludeCondition FromSelection(ISelectionNode selection) { + if (selection is null) + { + throw new ArgumentNullException(nameof(selection)); + } + IValueNode? skip = null; IValueNode? include = null; diff --git a/src/HotChocolate/Core/src/Execution/Processing/OperationCompiler.cs b/src/HotChocolate/Core/src/Execution/Processing/OperationCompiler.cs index 7dfd87729ea..127c1ccae8b 100644 --- a/src/HotChocolate/Core/src/Execution/Processing/OperationCompiler.cs +++ b/src/HotChocolate/Core/src/Execution/Processing/OperationCompiler.cs @@ -278,6 +278,15 @@ private void CompleteSelectionSet(CompilerContext context) ResolveOptimizers(context.Optimizers, selection.Field))); } } + + // We are waiting for the latest stream and defer spec discussions to be codified + // before we change the overall stream handling. + // + // For now we only allow streams on lists of composite types. + //if (selection.SyntaxNode.IsStreamable()) + // { + // var streamDirective = selection.SyntaxNode.GetStreamDirective(); + // } } selection.SetSelectionSetId(selectionSetId); @@ -462,10 +471,21 @@ private void CollectFields(CompilerContext context) (context.Schema.TryGetTypeFromAst(typeCondition, out IType typeCon) && DoesTypeApply(typeCon, context.Type))) { - includeCondition |= GetSelectionIncludeCondition(selection, includeCondition); + includeCondition = GetSelectionIncludeCondition(selection, includeCondition); if (directives.IsDeferrable()) { + var deferDirective = directives.GetDeferDirective(); + var nullValue = NullValueNode.Default; + var ifValue = deferDirective?.GetIfArgumentValueOrDefault() ?? nullValue; + + long ifConditionFlags = 0; + if (ifValue.Kind is not SyntaxKind.NullValue) + { + var ifCondition = new IncludeCondition(ifValue, nullValue); + ifConditionFlags = GetSelectionIncludeCondition(ifCondition, includeCondition); + } + var id = GetOrCreateSelectionSetId(selectionSet, context.Path); var variants = GetOrCreateSelectionVariants(id); var infos = new SelectionSetInfo[] { new(selectionSet, includeCondition) }; @@ -485,9 +505,20 @@ private void CollectFields(CompilerContext context) directives, selectionSetId: id, variants.GetSelectionSet(context.Type), - includeCondition); + includeCondition, + ifConditionFlags); context.Fragments.Add(fragment); + + // if we have if condition flags there will be a runtime validation if something + // shall be deferred, so we need to prepare for both cases. + // + // this means that we will collect the fields with our if condition flags as + // if the fragment was not deferred. + if (ifConditionFlags is not 0) + { + CollectFields(context, selectionSet, ifConditionFlags); + } } else { @@ -608,6 +639,44 @@ private SelectionVariants GetOrCreateSelectionVariants(int selectionSetId) return parentIncludeCondition; } + private long GetSelectionIncludeCondition( + IncludeCondition condition, + long parentIncludeCondition) + { + var pos = Array.IndexOf(_includeConditions, condition); + + if (pos == -1) + { + pos = _includeConditions.Length; + + if (pos == 64) + { + throw new InvalidOperationException(OperationCompiler_ToManyIncludeConditions); + } + + if (_includeConditions.Length == 0) + { + _includeConditions = new IncludeCondition[1]; + } + else + { + Array.Resize(ref _includeConditions, pos + 1); + } + + _includeConditions[pos] = condition; + } + + long selectionIncludeCondition = 2 ^ pos; + + if (parentIncludeCondition == 0) + { + return selectionIncludeCondition; + } + + parentIncludeCondition |= selectionIncludeCondition; + return parentIncludeCondition; + } + private CompilerContext RentContext(CompilerContext context) { if (_deferContext is null) @@ -681,7 +750,7 @@ private SelectionPath(string name, SelectionPath? parent = null) public bool Equals(SelectionPath? other) { - if (ReferenceEquals(null, other)) + if (other is null) { return false; } @@ -705,7 +774,7 @@ public bool Equals(SelectionPath? other) } public override bool Equals(object? obj) - => ReferenceEquals(this, obj) || obj is SelectionPath other && Equals(other); + => ReferenceEquals(this, obj) || (obj is SelectionPath other && Equals(other)); public override int GetHashCode() => HashCode.Combine(Name, Parent); diff --git a/src/HotChocolate/Core/src/Execution/Processing/Selection.cs b/src/HotChocolate/Core/src/Execution/Processing/Selection.cs index 33410edbdb6..2cf5d44b322 100644 --- a/src/HotChocolate/Core/src/Execution/Processing/Selection.cs +++ b/src/HotChocolate/Core/src/Execution/Processing/Selection.cs @@ -49,6 +49,11 @@ public class Selection : ISelection : new[] { includeCondition }; _flags = isInternal ? Flags.Internal : Flags.None; + + if (Type.IsListType()) + { + _flags |= Flags.List; + } } protected Selection(Selection selection) @@ -94,6 +99,9 @@ protected Selection(Selection selection) /// public TypeKind TypeKind => Type.Kind; + /// + public bool IsList => (_flags & Flags.List) == Flags.List; + /// public FieldNode SyntaxNode { get; private set; } @@ -298,6 +306,8 @@ private enum Flags { None = 0, Internal = 1, - Sealed = 2 + Sealed = 2, + List = 4, + Stream = 8 } } diff --git a/src/HotChocolate/Core/src/Execution/Processing/Tasks/ResolverTask.Execute.cs b/src/HotChocolate/Core/src/Execution/Processing/Tasks/ResolverTask.Execute.cs index b3e33dd7d1a..c3a1ed85c69 100644 --- a/src/HotChocolate/Core/src/Execution/Processing/Tasks/ResolverTask.Execute.cs +++ b/src/HotChocolate/Core/src/Execution/Processing/Tasks/ResolverTask.Execute.cs @@ -119,13 +119,13 @@ private async ValueTask ExecuteResolverPipelineAsync(CancellationToken cancellat return; } - /* // if we are not a list we do not need any further result processing. - if (!Selection.IsList) + if (!_selection.IsList) { return; } + /* if (Selection.IsStreamable) { StreamDirective streamDirective = diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/ISelection.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/ISelection.cs index 40ffa478ea8..17cd02b34ef 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/ISelection.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/ISelection.cs @@ -1,5 +1,6 @@ #nullable enable +using System.Diagnostics.SymbolStore; using HotChocolate.Language; using HotChocolate.Resolvers; using HotChocolate.Types; @@ -32,14 +33,19 @@ public interface ISelection : IOptionalSelection IType Type { get; } /// - /// The type that declares the field that is selected by this selection. + /// Gets the type kind of the selection. /// - IObjectType DeclaringType { get; } + TypeKind TypeKind { get; } /// - /// Gets the type kind of the selection. + /// Specifies if the return type fo this selection is a list. /// - TypeKind TypeKind { get; } + bool IsList { get; } + + /// + /// The type that declares the field that is selected by this selection. + /// + IObjectType DeclaringType { get; } /// /// Gets the field selection syntax node. diff --git a/src/HotChocolate/Core/src/Types/Types/Contracts/IObjectField.cs b/src/HotChocolate/Core/src/Types/Types/Contracts/IObjectField.cs index 4667687cc2d..0ed94150fe2 100644 --- a/src/HotChocolate/Core/src/Types/Types/Contracts/IObjectField.cs +++ b/src/HotChocolate/Core/src/Types/Types/Contracts/IObjectField.cs @@ -62,9 +62,4 @@ public interface IObjectField : IOutputField /// this property will return . /// MemberInfo? ResolverMember { get; } - - /// - /// Defines that the result of this field might be a stream. - /// - bool MaybeStream { get; } } diff --git a/src/HotChocolate/Core/src/Types/Types/ObjectField.cs b/src/HotChocolate/Core/src/Types/Types/ObjectField.cs index f9e20cdd584..b415d66d19b 100644 --- a/src/HotChocolate/Core/src/Types/Types/ObjectField.cs +++ b/src/HotChocolate/Core/src/Types/Types/ObjectField.cs @@ -109,11 +109,6 @@ internal ObjectField(ObjectFieldDefinition definition, int index) /// public override bool IsIntrospectionField { get; } - /// - /// Defines that the result of this field might be a stream. - /// - public bool MaybeStream { get; private set; } - protected override void OnCompleteField( ITypeCompletionContext context, ITypeSystemMember declaringMember, @@ -123,15 +118,6 @@ internal ObjectField(ObjectFieldDefinition definition, int index) CompleteExecutableDirectives(context); CompleteResolver(context, definition); - - // going forward we should rework the list detection in the ExtendedType to already - // provide us with the info if a type is an async enumerable. - if (Type.IsListType() && - definition.ResultType is { IsGenericType: true } resultType && - context.TypeInspector.GetType(resultType).Definition == typeof(IAsyncEnumerable<>)) - { - MaybeStream = true; - } } private void CompleteExecutableDirectives( diff --git a/src/HotChocolate/Core/test/Execution.Tests/DeferTests.cs b/src/HotChocolate/Core/test/Execution.Tests/DeferTests.cs index 08a7979a72f..327458aaf2b 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/DeferTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/DeferTests.cs @@ -198,19 +198,87 @@ public async Task Do_Not_Defer() .AddStarWarsTypes() .ExecuteRequestAsync( @"{ - hero(episode: NEW_HOPE) { + hero(episode: NEW_HOPE) { + id + ... deferred @defer(label: ""friends"", if: false) + } + } + + fragment deferred on Character { + friends { + nodes { id - ... deferred @defer(label: ""friends"", if: false) } } + }"); - fragment deferred on Character { - friends { - nodes { - id - } - } - }"); + await Assert.IsType(result).MatchSnapshotAsync(); + } + + [Fact] + public async Task Do_Not_Defer_If_Variable_Set_To_False() + { + Snapshot.FullName(); + + var request = QueryRequestBuilder.New() + .SetQuery( + @"query($if: Boolean!) { + hero(episode: NEW_HOPE) { + id + ... deferred @defer(label: ""friends"", if: $if) + } + } + + fragment deferred on Character { + friends { + nodes { + id + } + } + }") + .SetVariableValue("if", false) + .Create(); + + var result = + await new ServiceCollection() + .AddStarWarsRepositories() + .AddGraphQL() + .AddStarWarsTypes() + .ExecuteRequestAsync(request); + + await Assert.IsType(result).MatchSnapshotAsync(); + } + + [Fact] + public async Task Do_Defer_If_Variable_Set_To_True() + { + Snapshot.FullName(); + + var request = QueryRequestBuilder.New() + .SetQuery( + @"query($if: Boolean!) { + hero(episode: NEW_HOPE) { + id + ... deferred @defer(label: ""friends"", if: $if) + } + } + + fragment deferred on Character { + friends { + nodes { + id + } + } + }") + .SetVariableValue("if", true) + .Create(); + + var result = + await new ServiceCollection() + .AddStarWarsRepositories() + .AddGraphQL() + .AddStarWarsTypes() + .ExecuteRequestAsync(request); await Assert.IsType(result).MatchSnapshotAsync(); } diff --git a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/DeferTests.Do_Defer_If_Variable_Set_To_True.snap b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/DeferTests.Do_Defer_If_Variable_Set_To_True.snap new file mode 100644 index 00000000000..e61084663ac --- /dev/null +++ b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/DeferTests.Do_Defer_If_Variable_Set_To_True.snap @@ -0,0 +1,29 @@ +[{ + "data": { + "hero": { + "id": "2001" + } + }, + "hasNext": true +},{ + "label": "friends", + "path": [ + "hero" + ], + "data": { + "friends": { + "nodes": [ + { + "id": "1000" + }, + { + "id": "1002" + }, + { + "id": "1003" + } + ] + } + }, + "hasNext": false +}] diff --git a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/DeferTests.Do_Not_Defer.snap b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/DeferTests.Do_Not_Defer.snap index e61084663ac..7ecdf618784 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/DeferTests.Do_Not_Defer.snap +++ b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/DeferTests.Do_Not_Defer.snap @@ -1,29 +1,20 @@ -[{ +{ "data": { "hero": { - "id": "2001" + "id": "2001", + "friends": { + "nodes": [ + { + "id": "1000" + }, + { + "id": "1002" + }, + { + "id": "1003" + } + ] + } } - }, - "hasNext": true -},{ - "label": "friends", - "path": [ - "hero" - ], - "data": { - "friends": { - "nodes": [ - { - "id": "1000" - }, - { - "id": "1002" - }, - { - "id": "1003" - } - ] - } - }, - "hasNext": false -}] + } +} diff --git a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/DeferTests.Do_Not_Defer_If_Variable_Set_To_False.snap b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/DeferTests.Do_Not_Defer_If_Variable_Set_To_False.snap new file mode 100644 index 00000000000..7ecdf618784 --- /dev/null +++ b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/DeferTests.Do_Not_Defer_If_Variable_Set_To_False.snap @@ -0,0 +1,20 @@ +{ + "data": { + "hero": { + "id": "2001", + "friends": { + "nodes": [ + { + "id": "1000" + }, + { + "id": "1002" + }, + { + "id": "1003" + } + ] + } + } + } +}