Skip to content

from_definition fails to propagate possible_types through LateBoundType interface chains #5580

@myronmarston

Description

@myronmarston

Describe the bug

Schema.from_definition fails to populate possible_types for ancestor interfaces when a child interface appears before its parent interface in the SDL document. The parent becomes a LateBoundType placeholder. When it is later resolved, the child interface's concrete possible_types are not propagated up to the grandparent.

This causes to_definition to silently drop the affected interfaces (and any fields referencing them) because the Warden considers interfaces with no possible_types to be invisible.

Versions

graphql version: 2.5.21
rails (or other framework): N/A

GraphQL schema

3-interface hierarchy (Grandparent < Parent < Child) plus a concrete type Leaf. Each type lists only its direct parent in implements. Child appears before Parent:

type Query { grandparent: Grandparent }
interface Grandparent { id: ID! }
interface Child implements Parent { id: ID! }
type Leaf implements Child { id: ID! }
interface Parent implements Grandparent { id: ID! }

Steps to reproduce

Save the reproduction script below and run ruby graphql_ruby_bug_repro.rb. It tests three variants of the same hierarchy:

  1. BUG: Child defiend before Parentpossible_types(Grandparent) is empty
  2. WORKAROUND: all transitive interfaces listed explicitly → works
  3. CONTROL: parents defined before children (no LateBoundType) → works

Output:

graphql gem version: 2.5.21

BUG: child interface defined before parent
  possible_types(Child): ["Leaf"]  OK
  possible_types(Grandparent): []  BUG
  possible_types(Parent): ["Leaf"]  OK
  to_definition: DROPPED: Grandparent, Parent, Child

WORKAROUND: all transitive interfaces listed
  possible_types(Child): ["Leaf"]  OK
  possible_types(Grandparent): ["Leaf"]  OK
  possible_types(Parent): ["Leaf"]  OK
  to_definition: all interfaces visible

CONTROL: parents defined before children
  possible_types(Child): ["Leaf"]  OK
  possible_types(Grandparent): ["Leaf"]  OK
  possible_types(Parent): ["Leaf"]  OK
  to_definition: all interfaces visible
Full reproduction script
require "bundler/inline"

gemfile do
  source "https://rubygems.org"
  gem "graphql", ENV.fetch("GRAPHQL_GEM_VERSION", "2.5.21")
end

require "graphql"

cases = {
  "BUG: child interface defined before parent" => <<~GRAPHQL,
    type Query { grandparent: Grandparent }
    interface Grandparent { id: ID! }
    interface Child implements Parent { id: ID! }
    type Leaf implements Child { id: ID! }
    interface Parent implements Grandparent { id: ID! }
  GRAPHQL

  "WORKAROUND: all transitive interfaces listed" => <<~GRAPHQL,
    type Query { grandparent: Grandparent }
    interface Grandparent { id: ID! }
    interface Child implements Parent & Grandparent { id: ID! }
    type Leaf implements Child & Parent & Grandparent { id: ID! }
    interface Parent implements Grandparent { id: ID! }
  GRAPHQL

  "CONTROL: parents defined before children" => <<~GRAPHQL
    type Query { grandparent: Grandparent }
    interface Grandparent { id: ID! }
    interface Parent implements Grandparent { id: ID! }
    interface Child implements Parent { id: ID! }
    type Leaf implements Child { id: ID! }
  GRAPHQL
}

puts "graphql gem version: #{GraphQL::VERSION}"
puts

cases.each do |label, sdl|
  schema = GraphQL::Schema.from_definition(sdl)
  interfaces = schema.types.values.select { |t| t.kind.interface? && t.graphql_name != "Node" }

  puts label
  interfaces.sort_by(&:graphql_name).each do |iface|
    possible = schema.possible_types(iface).map(&:graphql_name)
    ok = possible.include?("Leaf")
    puts "  possible_types(#{iface.graphql_name}): #{possible.inspect}  #{ok ? 'OK' : 'BUG'}"
  end

  round_tripped = schema.to_definition
  missing = interfaces.reject { |iface| round_tripped.include?("interface #{iface.graphql_name}") }
  puts "  to_definition: #{missing.empty? ? 'all interfaces visible' : "DROPPED: #{missing.map(&:graphql_name).join(', ')}"}"
  puts
end

Expected behavior

All three interfaces should include Leaf in possible_types, regardless of the order types appear in the SDL document.

Actual behavior

possible_types(Grandparent) is empty when Child appears before Parent. This causes Grandparent to become invisible in to_definition, which cascades — since Query.grandparent references it, that field is dropped too, making Parent and Child also unreachable.

I believe the bug is in Schema::Addition#update_type_owner. When a LateBoundType is resolved and the types waiting for it include interfaces (not just concrete types), the interfaces' concrete possible_types are not propagated to the newly resolved type's ancestors.

Workaround

Listing all transitive interfaces explicitly in every implements clause (e.g. type Leaf implements Child & Parent & Grandparent) avoids the bug. This also lines up with the GraphQL Spec. Arguably, graphql-ruby should maybe raise an exception when that is not done.

Additional context

Discovered in ElasticGraph (discussion #1075), where generated SDL had alphabetical type ordering and implements clauses listing only direct parents. Interface Edge types were losing their node fields after a from_definition/to_definition round-trip.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions