diff --git a/src/StrawberryShake/CodeGeneration/src/CodeGeneration/Analyzers/FragmentHelper.cs b/src/StrawberryShake/CodeGeneration/src/CodeGeneration/Analyzers/FragmentHelper.cs index de8be54c391..50658760a09 100644 --- a/src/StrawberryShake/CodeGeneration/src/CodeGeneration/Analyzers/FragmentHelper.cs +++ b/src/StrawberryShake/CodeGeneration/src/CodeGeneration/Analyzers/FragmentHelper.cs @@ -321,6 +321,20 @@ private static void CollectFields( CollectFields(inlineFragment, outputType, fields, path); } + // A named fragment whose type condition is an abstract type (a union or + // interface) is not represented as its own interface for a concrete member, + // so we have to descend into it to reach the member selections it carries. + // Without this, member selections that are only reachable through such a + // fragment (for example `... AnimalDetails` where `AnimalDetails` is defined + // on the union) are lost and the generated member class comes out empty. + foreach (var namedFragment in fragmentNode.Nodes.Where( + t => t.Fragment.Kind == FragmentKind.Named + && t.Fragment.TypeCondition.IsAbstractType() + && t.Fragment.TypeCondition.IsAssignableFrom(outputType))) + { + CollectFields(namedFragment, outputType, fields, path); + } + foreach (var selection in fragmentNode.Fragment.SelectionSet.Selections.OfType()) { diff --git a/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/GeneratorTestHelper.cs b/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/GeneratorTestHelper.cs index 8582a33dd75..c44fc8e71f7 100644 --- a/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/GeneratorTestHelper.cs +++ b/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/GeneratorTestHelper.cs @@ -270,10 +270,10 @@ private static void CheckStrictMode() } } - private static ClientModel CreateClientModel( + public static ClientModel CreateClientModel( string[] sourceText, - bool strictValidation, - bool noStore) + bool strictValidation = true, + bool noStore = false) { var files = sourceText .Select(s => new GraphQLFile(Utf8GraphQLParser.Parse(s))) diff --git a/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/UnionTypeGeneratorTests.cs b/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/UnionTypeGeneratorTests.cs new file mode 100644 index 00000000000..3cfc2d3c63d --- /dev/null +++ b/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/UnionTypeGeneratorTests.cs @@ -0,0 +1,149 @@ +using static StrawberryShake.CodeGeneration.CSharp.GeneratorTestHelper; + +namespace StrawberryShake.CodeGeneration.CSharp; + +public class UnionTypeGeneratorTests +{ + private const string Schema = + "union Animal = Dog | Cat " + + "type Dog { id: ID! name: String barkVolume: Int } " + + "type Cat { id: ID! name: String meowVolume: Int } " + + "type Query { animal: Animal }"; + + private const string SchemaExtensions = "extend schema @key(fields: \"id\")"; + + [Fact] + public void UnionMemberClasses_Should_ContainSelectedFields_When_SelectedViaInlineFragments() + { + // arrange + const string query = + """ + query GetAnimal { + animal { + ... on Dog { name barkVolume } + ... on Cat { name meowVolume } + } + } + """; + + // act + var source = GenerateUnionClientSource(query); + + // assert + AssertMemberClassContainsProperties(source, "GetAnimal_Animal_Dog", "Name", "BarkVolume"); + AssertMemberClassContainsProperties(source, "GetAnimal_Animal_Cat", "Name", "MeowVolume"); + } + + [Fact] + public void UnionMemberClasses_Should_ContainSelectedFields_When_SelectedViaNamedFragmentOnUnion() + { + // arrange + // The named fragment is declared on the union type itself and contains the + // member inline fragments. This is the shape from issue 5992. + const string query = + """ + query GetAnimal { + animal { + ... AnimalDetails + } + } + + fragment AnimalDetails on Animal { + ... on Dog { name barkVolume } + ... on Cat { name meowVolume } + } + """; + + // act + var source = GenerateUnionClientSource(query); + + // assert + AssertMemberClassContainsProperties(source, "GetAnimal_Animal_Dog", "Name", "BarkVolume"); + AssertMemberClassContainsProperties(source, "GetAnimal_Animal_Cat", "Name", "MeowVolume"); + } + + [Fact] + public void UnionMemberClasses_Should_ContainSelectedFields_When_SelectedViaNamedFragmentsOnMembers() + { + // arrange + // Each named fragment is declared on a concrete union member. + const string query = + """ + query GetAnimal { + animal { + ... DogDetails + ... CatDetails + } + } + + fragment DogDetails on Dog { name barkVolume } + + fragment CatDetails on Cat { name meowVolume } + """; + + // act + var source = GenerateUnionClientSource(query); + + // assert + AssertMemberClassContainsProperties(source, "GetAnimal_Animal_Dog", "Name", "BarkVolume"); + AssertMemberClassContainsProperties(source, "GetAnimal_Animal_Cat", "Name", "MeowVolume"); + } + + private static string GenerateUnionClientSource(string query) + { + var clientModel = CreateClientModel([query, Schema, SchemaExtensions]); + + var result = CSharpGenerator.Generate( + clientModel, + new CSharpGeneratorSettings + { + Namespace = "Foo.Bar", + ClientName = "FooClient", + AccessModifier = AccessModifier.Public + }); + + Assert.False( + result.Errors.Any(), + "It is expected that the result has no generator errors!"); + + return string.Join( + "\n", + result.Documents + .Where(t => t.Kind == SourceDocumentKind.CSharp) + .Select(t => t.SourceText)); + } + + private static void AssertMemberClassContainsProperties( + string source, + string className, + params string[] propertyNames) + { + var classIndex = source.IndexOf( + $"public partial class {className}", + StringComparison.Ordinal); + + Assert.True( + classIndex >= 0, + $"Generated source does not contain class `{className}`."); + + var classBody = source[classIndex..]; + var nextClassIndex = classBody.IndexOf( + "public partial class ", + startIndex: "public partial class ".Length, + StringComparison.Ordinal); + + if (nextClassIndex >= 0) + { + classBody = classBody[..nextClassIndex]; + } + + foreach (var propertyName in propertyNames) + { + Assert.True( + classBody.Contains($" {propertyName} {{", StringComparison.Ordinal) + || classBody.Contains($" {propertyName} =>", StringComparison.Ordinal), + $"Generated class `{className}` is missing property `{propertyName}`. " + + $"Class body:\n{classBody}"); + } + } +}