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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ public bool BuildResult(
var error = errorTrie?.FindFirstError();
var canExecutionContinue =
BuildResultForInvalidSource(
source,
sourceValueKind,
target,
resultSelectionSet,
Expand Down Expand Up @@ -257,6 +258,7 @@ private static bool PropagateNullValues(CompositeResultElement result)
}

private bool BuildResultForInvalidSource(
SourceResultElement source,
JsonValueKind sourceValueKind,
CompositeResultElement target,
ResultSelectionSet resultSelectionSet,
Expand All @@ -272,6 +274,15 @@ private bool BuildResultForInvalidSource(
return true;
}

// A clean null source without an associated error is a legitimate
// "not found" state (e.g. a lookup that resolved to null), so each
// selected field is completed with its own nullability semantics
// instead of surfacing an unexpected execution error.
if (sourceValueKind is JsonValueKind.Null && error is null)
{
return CompleteNullSource(source, target, resultSelectionSet);
}

var fallbackError = error ??
ErrorBuilder.New()
.SetMessage("Unexpected Execution Error")
Expand All @@ -290,6 +301,58 @@ private bool BuildResultForInvalidSource(
return BuildErrorResult(target, resultSelectionSet, fallbackError, target.CompactPath);
}

/// <summary>
/// Completes the selection set of <paramref name="target"/> for a <c>null</c>
/// source, applying per-field nullability so that non-null fields produce a
/// non-null violation and nullable fields are set to <c>null</c>.
/// </summary>
/// <returns>
/// <c>true</c>, if the execution can continue.
/// <c>false</c>, if the execution needs to be halted.
/// </returns>
private bool CompleteNullSource(
SourceResultElement source,
CompositeResultElement target,
ResultSelectionSet resultSelectionSet)
{
foreach (var responseName in resultSelectionSet.ResponseNames)
{
if (!target.TryGetProperty(responseName, out var fieldResult)
|| fieldResult.IsInternal)
{
continue;
}

var selection = fieldResult.AssertSelection();
var childSet = resultSelectionSet.TryGetChild(selection.ResponseName);

if (!TryCompleteValue(
source,
JsonValueKind.Null,
fieldResult,
errorTrie: null,
selection,
selection.Type,
0,
childSet))
{
switch (_errorHandlingMode)
{
case ErrorHandlingMode.Propagate:
var didPropagateToRoot = PropagateNullValues(fieldResult);
if (didPropagateToRoot)
{
return false;
}

return ApplyPocketedErrors(target);
}
}
}

return ApplyPocketedErrors(target);
}

private static bool TryInitializeTargetObject(CompositeResultElement target)
{
if (!TryGetSelectionContext(target, out var selection, out var type)
Expand Down
148 changes: 148 additions & 0 deletions src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/LookupTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,88 @@ public async Task Fetch_OneOf_Lookup_With_Id()
await MatchSnapshotAsync(gateway, request, result);
}

[Fact]
public async Task Lookup_Should_Return_Null_When_Nested_Entity_Not_Found()
Comment on lines +145 to +146
{
// arrange
using var server1 = CreateSourceSchema(
"a",
b => b.AddQueryType<NullLookup.SourceSchema1.Query>());

using var server2 = CreateSourceSchema(
"b",
b => b.AddQueryType<NullLookup.SourceSchema2.Query>());

using var gateway = await CreateCompositeSchemaAsync(
[
("a", server1),
("b", server2)
]);

// act
using var client = GraphQLHttpClient.Create(gateway.CreateClient());

var request = new OperationRequest(
"""
{
resources {
displayImage {
id
}
}
}
""");

using var result = await client.PostAsync(
request,
new Uri("http://localhost:5000/graphql"),
TestContext.Current.CancellationToken);

// assert
await MatchSnapshotAsync(gateway, request, result);
}

[Fact]
public async Task Lookup_Should_Null_Field_When_Nullable_Entity_Not_Found()
{
Comment on lines +186 to +188
// arrange
using var server1 = CreateSourceSchema(
"a",
b => b.AddQueryType<NullableNullLookup.SourceSchema1.Query>());

using var server2 = CreateSourceSchema(
"b",
b => b.AddQueryType<NullableNullLookup.SourceSchema2.Query>());

using var gateway = await CreateCompositeSchemaAsync(
[
("a", server1),
("b", server2)
]);

// act
using var client = GraphQLHttpClient.Create(gateway.CreateClient());

var request = new OperationRequest(
"""
{
resources {
displayImage {
id
}
}
}
""");

using var result = await client.PostAsync(
request,
new Uri("http://localhost:5000/graphql"),
TestContext.Current.CancellationToken);

// assert
await MatchSnapshotAsync(gateway, request, result);
}

public static class NestedLookups
{
public static class SourceSchema1
Expand Down Expand Up @@ -276,4 +358,70 @@ public Author GetTopAuthor()
public record Author([property: Shareable] string Name);
}
}

public static class NullLookup
{
public static class SourceSchema1
{
public record Resource(int Id, [property: Shareable] File DisplayImage);

[EntityKey("originId")]
public record File(int OriginId);

public class Query
{
public IEnumerable<Resource> GetResources()
=> [new Resource(1, new File(42))];
}
}

public static class SourceSchema2
{
public record File(int OriginId, string Id);

public class Query
{
[Lookup]
[Internal]
public File? GetFileByOriginId(int originId)
=> null;
}
}
}

public static class NullableNullLookup
{
public enum FileOrigin
{
Internal,
External
}

public static class SourceSchema1
{
public record Resource(int Id, File? DisplayImage);

[EntityKey("originId origin")]
public record File(Guid OriginId, FileOrigin Origin);

public class Query
{
public IEnumerable<Resource> GetResources()
=> [new Resource(1, new File(Guid.Empty, FileOrigin.External))];
}
}

public static class SourceSchema2
{
public record File(Guid OriginId, FileOrigin Origin, string Id);

public class Query
{
[Lookup]
[Internal]
public File? GetFileByOriginIdAndOrigin(Guid originId, FileOrigin origin)
=> null;
}
}
}
}
Loading
Loading