From f87dd0beea553c4b5ec8daf5ef511b1167969165 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 12:39:58 +0200 Subject: [PATCH] fix(strawberry-shake): populate union member classes for fragment selections When union members were selected through a named fragment whose type condition is the union itself (or another abstract type), the generated member classes came out empty: the requested fields were dropped and only __typename remained. The cause is in FragmentHelper.CollectFields, which builds the field set for a concrete union member. It descended into inline fragment nodes but not into named fragment nodes on an abstract type. A named fragment on the union does not become its own interface for a concrete member, so the member inline fragments nested inside it were never reached and no fields were collected. This left the member interface (and therefore the member class) empty while the code still compiled. The fix descends into named fragment nodes whose abstract type condition applies to the concrete member type, so the nested member selections are collected. Inline-fragment and concrete named-fragment selections are unaffected, and existing generated client snapshots are byte-identical. Adds UnionTypeGeneratorTests covering union members selected via inline fragments, via a named fragment on the union, and via named fragments on the members. The test drives the generator directly and asserts the member classes contain the requested properties, because the previous empty output still compiled. Fixes ChilliCream/graphql-platform#5992 --- .../Analyzers/FragmentHelper.cs | 14 ++ .../GeneratorTestHelper.cs | 6 +- .../UnionTypeGeneratorTests.cs | 149 ++++++++++++++++++ 3 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/UnionTypeGeneratorTests.cs 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}"); + } + } +}