Skip to content

fix(strawberryshake): resolve output type for composite field selected directly and via fragment#9

Open
cliedeman wants to merge 2 commits into
fork-ci-basefrom
fix/ss-fragment-direct-and-spread-output-type
Open

fix(strawberryshake): resolve output type for composite field selected directly and via fragment#9
cliedeman wants to merge 2 commits into
fork-ci-basefrom
fix/ss-fragment-direct-and-spread-output-type

Conversation

@cliedeman

@cliedeman cliedeman commented Jun 27, 2026

Copy link
Copy Markdown

The bug

A composite (object/interface) field selected both directly and via a fragment (an inline fragment of the same type, or a named fragment spread) at the same selection level makes CSharpGenerator.Generate throw:

System.InvalidOperationException: Could not find an output type for the specified field syntax.

There is no upstream issue for this; it is described here.

Minimal repros (all valid GraphQL, all threw before this change)

query Q { me { avatar { url } ... on User { avatar { width } } } }
query Q { me { avatar { url } ...UF } } fragment UF on User { avatar { width } }
query Q { me { bestFriend { id } ... on User { bestFriend { name } } } }

Cases that already worked and still work (regression guards)

# both direct
query Q { me { avatar { url } avatar { width } } }
# both in fragments
query Q { me { ... on User { avatar { url } } ... on User { avatar { width } } } }

Root cause

When the same composite field is selected more than once at a level, the selections are merged onto a single canonical FieldNode. Two code paths disagree on which node is canonical:

  • FieldCollector.ResolveFieldSelection merges in document order, so the direct selection (avatar { url }) wins and becomes FieldSelection.SyntaxNode. This is the node used to descend into the child field and to call IDocumentAnalyzerContext.RegisterSelectionSet, so the selection-set lookup is keyed on (Image, { url }).
  • FragmentHelper.CollectFields builds the parent type model's fields by processing inline-fragment nodes before top-level fields, so the fragment selection (avatar { width }) wins and becomes the parent property's OutputFieldModel.SyntaxNode.

Later, TypeDescriptorMapper.GetFieldTypeDescriptor resolves the parent property's type via OperationModel.TryGetFieldResultType, which looks up _selectionSets[(fieldNamedType, fieldSyntax.SelectionSet)] and then matches an interface OutputTypeModel by reference-equality on the selection-set node (OperationModel.cs, TryGetFieldResultType). The lookup key is (Image, { width }), but only (Image, { url }) was registered, so the lookup misses, the fallback node matches no interface output type, and GetFieldTypeDescriptor throws.

The two control cases work precisely because both selections come from the same kind of node (both direct, or both fragments), so both merge orders pick the same canonical node and the lookup key matches.

The fix

Track the merged-away duplicate field nodes on FieldSelection (Duplicates), populate them in FieldCollector.ResolveFieldSelection, and in InterfaceTypeSelectionSetAnalyzer register each duplicate's sub-selection set against the same result type. The property type then resolves regardless of which equivalent node the parent model captured.

This makes the mixed direct+fragment case behave identically to the already-working homogeneous cases (the generated IQ_Me_Avatar interface is the same shape it produces for both direct and both in fragments). Deep-merging the duplicate sub-selections into one interface is a separate, pre-existing analyzer characteristic and is intentionally out of scope.

Files changed (production)

  • src/StrawberryShake/CodeGeneration/src/CodeGeneration/Analyzers/FieldSelection.cs — adds Duplicates.
  • src/StrawberryShake/CodeGeneration/src/CodeGeneration/Analyzers/FieldCollector.cs — records merged-away duplicates.
  • src/StrawberryShake/CodeGeneration/src/CodeGeneration/Analyzers/InterfaceTypeSelectionSetAnalyzer.cs — registers duplicate sub-selection sets.

Tests

Adds FragmentDirectAndSpreadGeneratorTests (new, isolated class) covering the three repros plus the two control cases. AssertResult runs the generator (so a throw fails the test), snapshots the output, and Roslyn-compiles it.

Verification

  • Before: the three repro tests fail with InvalidOperationException: Could not find an output type for the specified field syntax. at TypeDescriptorMapper.GetFieldTypeDescriptor.
  • After: all 5 new tests pass.
  • Full StrawberryShake.CodeGeneration.CSharp.Tests suite: 194 passed, 1 pre-existing skip, 0 failed. No existing snapshot changed; only the 5 new snapshots were added.

— Claude
🤖 Generated with Claude Code

…d directly and via fragment

A composite (object/interface) field selected BOTH directly AND via a fragment
(an inline fragment of the same type OR a named fragment spread) at the same
selection level made CSharpGenerator.Generate throw:

  System.InvalidOperationException: Could not find an output type for the
  specified field syntax.

Root cause: the field selections are merged onto a single canonical FieldNode,
but two code paths disagree on which node is canonical. FieldCollector merges in
document order (direct selection wins), while FragmentHelper builds the parent
type model's fields by processing inline fragments first (fragment selection
wins). The parent property therefore referenced a merged-away duplicate node,
whose sub-selection set was never registered in OperationModel's selection-set
lookup, so TypeDescriptorMapper.GetFieldTypeDescriptor could not resolve the
field's result type and threw.

Fix: track the merged-away duplicate field nodes on FieldSelection and register
their sub-selection sets against the same result type in
InterfaceTypeSelectionSetAnalyzer, so the property type resolves regardless of
which equivalent node the parent model captured. This makes the mixed
direct+fragment case behave identically to the already-working homogeneous cases
(both direct, both in fragments).

Adds FragmentDirectAndSpreadGeneratorTests covering the three repros plus the
two control cases.
@github-actions

Copy link
Copy Markdown

Patch coverage

84.2% of changed lines covered (32/38)

File Covered Changed Patch %
…/src/CodeGeneration/Analyzers/FieldCollector.cs 15 19 78.9% 🔴
…/src/CodeGeneration/Analyzers/FieldSelection.cs 4 5 80.0% 🟡
…/Analyzers/InterfaceTypeSelectionSetAnalyzer.cs 13 14 92.9% 🟡
Uncovered changed lines (JSON)
{
  "sha": "7d0dc2e5c2b492248c71362456156003c5c99a71",
  "files": [
    { "path": "src/StrawberryShake/CodeGeneration/src/CodeGeneration/Analyzers/FieldCollector.cs", "ranges": [[171, 174]] },
    { "path": "src/StrawberryShake/CodeGeneration/src/CodeGeneration/Analyzers/FieldSelection.cs", "ranges": [[43, 43]] },
    { "path": "src/StrawberryShake/CodeGeneration/src/CodeGeneration/Analyzers/InterfaceTypeSelectionSetAnalyzer.cs", "ranges": [[163, 163]] }
  ]
}

Project coverage: 52.4% (215319/411064 lines)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants