How to Resolve Overlapping DjangoObjectTypes with Graphene and Graphene Relay

If your schema has multiple types defined using DjangoObjectType for the same model in a Union, selections that work on the Union won't necessarily work as is on Relay node queries.Β 

For example, suppose we had a model schema like this:

class Record(models.Model):
    record_type = models.CharField(max_length=12)
    
    @property
    def is_disco(self) -> bool:
        return self.record_type == "DISCO"

    @property
    def is_punk(self) -> bool:
        return self.record_type == "PUNK"

class Disco(models.Model):
    record = models.OneToOneField(Record, related_name="disco_record")
    bpm = models.IntegerField()


class Punk(models.Model):
    record = models.OneToOneField(Record, related_name="punk_record")
    max_chords = models.IntegerField()

Our application cares Records and, depending on the record_type, the type of meta information we want to manage changes. As such we create a new model with a OneToOneField to our record for each type we plan on managing.

When we query our records we wan to only worry about Records, so define our GraphQL types accordingly.

class DiscoRecord(graphene_django.DjangoObjectType):
    class Meta:
        model = models.Record
    
    bpm = graphene.IntegerField(required=True)

    @classmethod
    def get_node(cls, info, id) -> models.Record:
        # Allow our object to be fetchable as a Relay Node
        return models.Record.objects.get(pk=id)
    
    def resolve_bpm(record: models.Record, **kwargs) -> int:
        return record.disco_record.bpm

class PunkRecord(graphene_django.DjangoObjectType):
    class Meta:
        model = models.Record
    
    max_chords = graphene.IntegerField(required=True)
    
    @classmethod
    def get_node(cls, info, id) -> models.Record:
        # Allow our object to be fetchable as a Relay Node
        return models.Record.objects.get(pk=id)
    
    def resolve_max_chords(record: models.Record, **kwargs) -> int:
        return record.punk_record.max_chords


class Record(graphene.Union):
    class Meta:
        types = (DiscoRecord, PunkRecord)

    @classmethod
    def resolve_type(
        cls, instance: models.Record, info
    ) -> Union[Type[DiscoRecord], Type[PunkRecord]]:
        # Graphene is unable to accurately determine which type it should resolve without help
        # because the unioned types are all DjangoObjectTypes for the same Record class.
        if instance.is_disco:
            return DiscoRecord
        elif instance.is_punk:
            return PunkRecord
        raise ValueError("Unknown record type")

Because we have the resolve_type @classmethod defined in our Union, Graphene can correctly determine the record type. Without that we'd get an error any time we tried to resolve values that only exist on the PunkRecord or DiscoRecord type.

So if we had a records query that returned our Record Union, we could query it as follows without any issues.

query {
    records {
        ... on DiscoRecord {
            bpm
        }
        ... on PunkRecord {
            maxChords
        }
    }
}

But what about the Relay node query? The query looks quite similar to our records query.

query {
    node(id: "fuga") {
        ... on DiscoRecord {
            bpm
        }
        ... on PunkRecord {
            maxChords
        }
    }
}

However, and this is the key difference, node does not return our Union type, but rather our individual DiscoRecord / PunkRecord type. And since both of those types are technically Record types (because of the same Django meta class), any PunkRecords will be resolved asΒ  DiscoRecords and return an error when we try to resolve Punk only fields.

In order for node to be able to differentiate between the Punk and Disco at the type level we need one more is_type_of classmethod defined on our types.

class DiscoRecord(graphene_django.DjangoObjectType):
    ...
    @classmethod
    def is_type_of(cls, root, info) -> bool:
        # When a DiscoRecord is resolved as a node it does not use the Union type
        # determine the object's type.
        # Only allow this type to be used with Disco records.
        if isinstance(root, models.Record):
            return root.is_disco
        return False

class PunkRecord(graphene_django.DjangoObjectType):
    ...
    @classmethod
    def is_type_of(cls, root, info) -> bool:
        # When a PunkRecord is resolved as a node it does not use the Union type
        # to determine the object's type.
        # Only allow this type to be used with Punk records.
        if isinstance(root, models.Record):
            return root.is_punk
        return False

This way, when Graphene is looping through all of our types trying to determine which type to use for a given Record, we can inspect the actual record and prevent an erroneous match.

This is obvious in retrospect. Although our GraphQL query selectors are exactly the same the root type is different and as such requires a bit more instruction to resolve the appropriate type.
Interactions
2