Skip to content

fix(codegen): recursively resolve nested fragment spreads in generated documents #423

Description

@wax911

Description Of Bug

Generated GraphQL document strings can contain unresolved nested fragment spreads because codegen only inlines fragment references that are directly referenced by an operation. Fragments referenced from inside another fragment are left as bare ...FragmentName spreads without appending the corresponding fragment <Name> on <Type> { ... } definitions.

This produces invalid request documents for GraphQL servers because named fragment spreads must refer to fragment definitions that exist within the same document.

Consumer Impact

This was found during migration of the AniTrend Android app from asset-based GraphQL lookup to codegen-first lookup using retrofit-graphql 0.12.0.

Consumer stack:

  • Project: AniTrend Android app, package com.mxt.anitrend
  • Runtime: co.anitrend.retrofit.graphql.converter.request.GraphRequestConverter
  • Codegen plugin: com.github.AniTrend.retrofit-graphql:co.anitrend.retrofit.graphql.codegen.gradle.plugin:0.12.0
  • Runtime library: com.github.AniTrend.retrofit-graphql:runtime:0.12.0
  • GraphQL API target: AniList GraphQL API

Observed through Chucker: outgoing requests contain nested fragment spreads such as:

...MediaTitleFragment
...MediaImageFragment
...FuzzyDateFragment
...AiringScheduleFragment
...MediaListFragmentMini

but the request body does not include matching fragment definitions such as:

fragment MediaTitleFragment on MediaTitle {
  english
  romaji
  native
  userPreferred
}

The server therefore receives a document with undefined fragment spreads. Depending on server validation and execution behavior, this can produce validation errors or missing/empty response fields.

Concrete Reproduction Shape

Consumer query:

query MediaBrowse($id: Int, $page: Int, $perPage: Int, ...) {
  Page(page: $page, perPage: $perPage) {
    pageInfo {
      ... PageInfoFragment
    }
    media(id: $id, ...) {
      ... MediaCoreFragment
    }
  }
}

MediaCoreFragment contains nested spreads:

fragment MediaCoreFragment on Media {
  id
  title {
    ... MediaTitleFragment
  }
  coverImage {
    ... MediaImageFragment
  }
  startDate {
    ... FuzzyDateFragment
  }
  endDate {
    ... FuzzyDateFragment
  }
  nextAiringEpisode {
    ... AiringScheduleFragment
  }
  mediaListEntry {
    ... MediaListFragmentMini
  }
}

Generated document output in GraphQLDocuments.kt currently inlines the first-level spread, for example ...MediaCoreFragment -> ... on Media { ... }, but leaves the nested spreads intact. The generated document file contains no fragment definitions, so those nested spreads are unresolved at runtime.

Current Codegen Root Cause

codegen-core has a FragmentResolver that claims to recursively resolve nested fragments, but the current implementation only includes fragment definitions whose names are directly referenced by the original operation document:

val referencedNames = collectReferencedNames(document)
val relevantDefinitions = fragmentDefinitions.filterKeys { it in referencedNames }

When the original operation directly references only MediaCoreFragment, relevantDefinitions includes MediaCoreFragment but excludes fragments referenced inside MediaCoreFragment, such as MediaTitleFragment, MediaImageFragment, and FuzzyDateFragment.

The existing nested-fragment test also encodes this limitation. It directly references both the parent and nested fragment from the operation and comments that this is necessary because relevantDefinitions requires direct references for each fragment to be eligible for inlining.

That test does not cover the real consumer shape where the operation references only the parent fragment and the parent fragment references nested fragments transitively.

Runtime Path

The runtime is likely not the correct fix location.

GraphRequestConverter.resolveQuery() resolves the operation name, calls registry.document(operationName), and serializes the returned document string as the query field. It faithfully sends whatever GraphQLDocuments.kt generated.

Therefore the fix belongs in codegen document composition, not runtime request conversion.

Expected Behaviour

For every generated operation document:

  • No named fragment spread should remain unresolved.
  • Every named spread should either be fully inlined or have a matching fragment definition appended to the final document string.
  • Nested/transitive fragment spreads should be resolved recursively.
  • Missing fragment definitions should fail codegen with a clear error naming the missing fragment and source operation.
  • Fragment cycles should fail codegen with a clear error that includes the cycle path.

Recommended Fix Direction

Choose one document composition strategy and make it explicit.

Option A: Recursive inline fragments

Recursively inline every named fragment spread as an inline fragment until no named spreads remain.

Example target shape:

query MediaBrowse(...) {
  Page(...) {
    media(...) {
      ... on Media {
        title {
          ... on MediaTitle {
            english
            romaji
            native
            userPreferred
          }
        }
      }
    }
  }
}

Option B: Append transitive fragment definitions

Keep named spreads, but append every transitively referenced fragment definition to the generated operation document.

Example target shape:

query MediaBrowse(...) {
  Page(...) {
    media(...) {
      ...MediaCoreFragment
    }
  }
}

fragment MediaCoreFragment on Media {
  title {
    ...MediaTitleFragment
  }
}

fragment MediaTitleFragment on MediaTitle {
  english
  romaji
  native
  userPreferred
}

Option B is often easier to reason about, preserves author-authored GraphQL more closely, and avoids excessive duplication across deeply nested fragments. Option A is also acceptable if implemented correctly and tested thoroughly.

Required Tests

Add codegen-core tests for the real consumer shape:

Parent fragment references nested fragment, operation references only parent

Input:

query GetMedia {
  Media(id: 1) {
    ...MediaCoreFragment
  }
}

fragment MediaCoreFragment on Media {
  title {
    ...MediaTitleFragment
  }
}

fragment MediaTitleFragment on MediaTitle {
  romaji
  english
}

Expected:

  • Generated document contains no unresolved ...MediaTitleFragment, if using inline strategy.
  • Or generated document includes fragment MediaTitleFragment on MediaTitle, if using append-definition strategy.
  • The final document is valid according to GraphQL fragment-spread validation rules.

Deep transitive fragment chain

Use at least three levels:

Operation -> FragmentA -> FragmentB -> FragmentC

Expected: all levels are resolved.

Sibling nested fragments

One parent fragment references multiple nested fragments:

MediaCoreFragment -> MediaTitleFragment
MediaCoreFragment -> MediaImageFragment
MediaCoreFragment -> FuzzyDateFragment

Expected: all sibling fragments are resolved.

Shared nested fragment

Two parent fragments reference the same nested fragment.

Expected:

  • no duplicate fragment definitions if using append-definition strategy;
  • no unresolved spreads if using inline strategy.

Missing nested fragment

Input:

Operation -> FragmentA -> MissingFragment

Expected: codegen fails with an error naming MissingFragment and the operation being generated.

Fragment cycle

Input:

FragmentA -> FragmentB -> FragmentA

Expected: codegen fails with a cycle error that identifies the path.

Acceptance Criteria

  • Generated GraphQLDocuments.kt contains valid executable GraphQL documents for operations with nested fragments.
  • No generated document contains a named fragment spread whose definition is absent from that same document.
  • The AniTrend Android MediaBrowse generated document resolves all nested fragments under MediaCoreFragment, including:
    • MediaTitleFragment
    • MediaImageFragment
    • FuzzyDateFragment
    • AiringScheduleFragment
    • MediaListFragmentMini
  • Existing first-level fragment behavior remains intact.
  • Missing nested fragments fail at codegen time, not runtime.
  • Cyclic nested fragments fail at codegen time.
  • Runtime GraphRequestConverter does not need to perform fragment resolution.

Related Context

The previous asset-based annotation scanner handled this by appending fragment definitions into the in-memory document before dispatch. Codegen needs to preserve that behavior so generated registry documents are equivalent to the previously working asset path.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions