Union types in GraphQL

Motivation


Support union types in GraphQL as specified in the GraphQL spec.

User Impact


Users will now be able to able to use unions with our GraphQL. It will enhance the user experience.

Implementation


Introduction

As per GraphQL spec:

GraphQL Unions represent an object that could be one of a list of GraphQL Object types, but provides for no guaranteed fields between those types. So no fields may be queried on this type without the use of type refining fragments or inline fragments.

Unions are never valid inputs

Union types have the potential to be invalid if incorrectly defined.

  1. A Union type must include one or more unique member types.
  2. The member types of a Union type must all be Object base types; Scalar, Interface and Union types must not be member types of a Union. Similarly, wrapping types must not be member types of a Union.

So, we will allow defining a schema like following with union:

enum Category {
  Fish
  Amphibian
  Reptile
  Bird
  Mammal
  InVertebrate
}

interface Animal {
  id: ID!
  category: Category @search
}

type Dog implements Animal {
  breed: String @search
}

type Parrot implements Animal {
  repeatsWords: [String]
}

type Cheetah implements Animal {
  speed: Float
}

type Human {
  name: String!
  pets: [Animal!]!
}

union HomeMember = Dog | Parrot | Human

type Zoo {
  id: ID!
  animals: [Animal]
  city: String
}

type Home {
  id: ID!
  address: String
  members: [HomeMember]
}

Example

So, when you want to query members in a home, you will be able to do a GraphQL query like this:

query {
  queryHome {
    address
    members {
      ... on Animal {
        category
      }
      ... on Dog {
        breed
      }
      ... on Parrot {
        repeatsWords
      }
      ... on Human {
        name
      }
    }
  }
}

the above GraphQL query will be rewritten to DQL in following way:

query {
  queryHome(func: type(Home)) {
    address: Home.address
    members: Home.members {
      category: Animal.category
      breed: Dog.breed
      repeatsWords: Parrot.repeatsWords
      name: Human.name
    }
  }
}

And the results of the GraphQL query will look like the following:

{
  "data": {
    "queryHome": {
      "address": "Earth",
      "members": [
        {
          "category": "Mammal",
          "breed": "German Shepherd"
        }, {
          "category": "Bird",
          "repeatsWords": ["Good Morning!", "I am a GraphQL parrot"]
        }, {
          "name": "Alice"
        }
      ]
    }
  }
}

Querying

Unions can be queried only as a field of some type.

The Home type in the given schema will be modified so that the member field will have arguments for filtering and pagination:

type Home {
  id: ID!
  address: String

  # **ORDERING**
  # Note that there is no `order` argument. Union queries can't be ordered, but you can filter and paginate them.
  # I feel, Ordering on union based on a field doesn't make sense because a union doesn't have fields of its own.
  # Also, it is not possible at present because in DQL combining ordered results isn't supported.
  # The results will be ordered in the increasing order of uid of each node.
  
  # **Filtering**
  # Not specifying any filter at all or specifying any of the null values for this filter would mean query all members.
  # See comments on the Filter definition for a list of what is considered as a null value.
  members(filter: HomeMemberFilter, first: Int, offset: Int): [HomeMember]
}

# Null values for this filter are:
# 1. null
# 2. {}
# 3. Any other combination of its fields where all of its fields have null values.
#    Not specifying a field in the filter input will be considered as a null value for that field.
#    This principle results in {} as null value for this filter in point 2.
input HomeMemberFilter {
	# `homeMemberTypes` is used to specify which types to report back.
	# It is necessary because there can be scenarios like someone wants to query all parrots and humans but have a filter on dogs,
	# and the other case where someone doesn't want to query parrots and humans at all but still have a filter on dogs.
	# It helps in distinguishing those cases.

	# `homeMemberTypes` doesn't have any null value other than it not being specified.
	# Note that specifying [] as the value would mean query nothing.
	# This behaviour is similar to specifying { id: [] } in other filter types.

	# Also note that the semantics of not specifying this field at all 
	# (i.e. you don't want to set a filter based on the concrete type) 
	# is similar to specifying [dog, parrot, human] as the value.
	# Although, the underlying DQL query will be different for both the cases.
	homeMemberTypes: [HomeMemberType]

	# **Null values for dogFilter**
	# 1. null
	# 2. {}
	# specifying a null value for this field means query all dogs
	dogFilter: DogFilter

	# **Null values for parrotFilter**
	# 1. null
	# 2. {}
	# specifying a null value for this field means query all parrots
	parrotFilter: ParrotFilter
	# note that there is no HumanFilter because the Human type wasn't filterable
}

enum HomeMemberType {
	dog
	parrot
	human
}

input DogFilter {
	id: [ID!]
	category: Category_hash
	breed: StringTermFilter
	and: DogFilter
	or: DogFilter
	not: DogFilter
}

input ParrotFilter {
	id: [ID!]
	category: Category_hash
	and: ParrotFilter
	or: ParrotFilter
	not: ParrotFilter
}

It will enable one to query a union field as shown in the example section with filters and pagination.

Let’s repeat the same example, but this time with filter and pagination arguments and see how it will be rewritten to DQL:

query {
  queryHome {
    address
    members (
      filter: {
		homeMemberTypes: [dog, parrot] # means we don't want to query humans
		dogFilter: {
			# means in Dogs, we only want to query "German Shepherd" breed
			breed: { allofterms: "German Shepherd"}
		}
		# not specifying any filter for parrots means we want to query all parrots
      }
      first: 5
      offset: 10
    ) {
      ... on Animal {
        category
      }
      ... on Dog {
        breed
      }
      ... on Parrot {
        repeatsWords
      }
      ... on HomeMember {
        name
      }
    }
  }
}

It would be re-written to DQL as:

query {
  queryHome(func: type(Home)) {
    address: Home.address
    members: Home.members @filter( (type(Dog) AND allofterms(Dog.breed, "German Shepherd")) OR (type(Parrot)) ) {
      category: Animal.category
      breed: Dog.breed
      repeatsWords: Parrot.repeatsWords
      name: HomeMember.name
    }
  }
}

Mutations

A node can be added to a union field in some type.
For the example schema, members can be added to Home. We will add HomeMemberRef to the schema, and modify it like so:

# Note that only one of dogRef, parrotRef, and humanRef should be given at a time.
# Giving more than one of them together will raise an error.
input HomeMemberRef {
	dogRef: DogRef
	parrotRef: ParrotRef
	humanRef: HumanRef
}

input AddHomeInput {
	address: String
	members: [HomeMemberRef]
}

type Mutation {
	addHome(input: [AddHomeInput!]!): Home
}

Interaction of existing directives with union

  1. @hasInverse: Not applicable, as unions don’t have fields of their own.
  2. @search: Not applicable.
  3. @dgraph: Not applicable. Unions aren’t stored in Dgraph as such, but concrete types are. So, doesn’t make sense on Union.
  4. @id: Not applicable.
  5. @withSubscription: Should work as expected.
  6. @secret: Not applicable, as unions don’t have fields of their own
  7. @auth: @auth can’t be applied directly on the union type because there won’t be any queries/mutations generated for a union type, but it is possible to use union fields inside auth queries using the filter on the union field. Also, one may want to put a filter on a field inside the selection set of a union field in auth queries. Both of these scenarios are presented below:
    # it would return homes only if they have a dog member with its breed as the $BREED in auth JWT
    # AND have a human member who has a parrot as a pet.
    type Home @auth(
      query: { and: [
         { rule: """
            query ($BREED: String!) { 
                queryHome {
                    members(filter: { homeMemberTypes: [dog], dogFilter: { breed: { allofterms: $BREED } } } ) { 
                        __typename
                    } 
                } 
            }"""
         }
         { rule: """
            query { 
                queryHome {
                    members(filter: { homeMemberTypes: [human] } ) { 
                        ... on Human {
                            pets(filter: { category: { eq: PARROT } }) {
                                __typename
                            }
                        }
                    } 
                } 
            }"""
         }]
      }) {
      id: ID!
      address: String
      members: [HomeMember]
    }
    
    For the first version in the master branch, @auth may not be supported with unions, as it would require fragments to work inside @auth queries. This may be worked on later but should make its way to the final v20.11 release.
  8. @custom: Custom queries and mutations returning union will work as expected. Custom field returning union and a union having a custom field in one of its types should also work as expected.
  9. @remote: Will work as expected.
  10. @cascade: Won’t be supported on the union field. But, will work on fields inside the union field’s selection set.

References


  1. GraphQL Intro: https://graphql.org/learn/schema/#union-types
  2. GraphQL Spec on Unions: GraphQL
  3. Several Discuss posts:
  4. GitHub’s GraphQL Schema: graphql-schema/schema.graphql at main · octokit/graphql-schema · GitHub

How does one add/update a pet while doing an add/update on Home?

We’ll have to make sure that responses only have either category and breed (if its a dog) or category and repeatsWords (if its a parrot).

Do we need this field? If both filters are nil, then we can return everything. Else we can just return data for the filter that is specified. OR add more comments about how different scenarios work with the current design.

Can you add some more examples of how these queries would be rewritten into equivalent ± queries?

This looks like it is just a wrapper around what we already provide so do we need to support this? I think we can skip it in the first version until we come up with some unique usecases that we can support here.

1 Like

Updated the RFC with example schema.

Since, while adding a union as a field of some type, the entities added in a union should be correct according to their type and can’t contain fields from some other type, so while retrieving them back too they should be correct.

added more comments

Done

Put a note that it won’t be supported initially.

1 Like

I don’t even know if this is possible - doesn’t really look sensible to me. How could I even use

input UpdateHomeMemberInput {
	updateDogInput: [UpdateDogInput!]
	updateParrotInput: [UpdateParrotInput!]
}

What would an update with a filter and this data payload mean? If it means match all the things that match the filter and if they are a dog, use the dog update, but if they are a parrot, use the parrot update … then that’s just a complicated way of saying what you can already say in a single mutation request with updateDog and updateParrot.

At the moment queryDog with no args means query all dogs (unfiltered) but this way of doing it is the opposite queryHomeMember with no args would mean query nothing. I think this should be the other way around. Maybe even not: { dogFilter: null } would mean no dogs can match. - pretty sure the semantics at the moment is that queryDog(filter: null) is interpreted as the filter is null, so everything matches.

This brings me to another thought - if there is no addHomeMember mutation, how can I query for HomeMembers ? It looks like in the way it’s presented that the union types only make sense as the types of fields … which means that the only way access them should be by following an edge inside a type to a union. So the query type on it’s own doesn’t make sense to me - seems to only make sense as a filter on the fields inside a type.

Also doesn’t make semantic sense here - for example in your schema everything that’s a dog, or parrot becomes an instance of type HomeMember, but I don’t think that’s the intention of unions. I think the intention is that a particular parrot or dog might be the member of a particular home because it’s linked through the members field of a particular home.

Feels like the ± translations should be pretty fine. The hard part here is going to be auth. I don’t think it’s right to have no answer to auth at all - we wouldn’t want to paint ourselves into a corner where auth couldn’t be applied or wasn’t safe. I think we should consider what the implications and possible solutions are, even if we don’t deliver it in a first version.

1 Like

This clarified my understanding. Removed all the queries and mutations from the auto-generated CRUD API.

Added more comments to clarify the working when no filter is given. So, querying members in a home with no args would mean query all the members in that home.

Auth will also be supported on unions. Added more comments about it.

Could you exlplain this in more detail? Would this not be supported at first?

expanding parts of your schema for example pruposes:

interface Animal {
  id: ID!
  category: Category @search
  name: String @search(by: [hash])
}

interface HomeBody {
  hasHome: Home @hasInverse(field: members)
}

type Dog implements Animal & HomeBody {
  breed: String @search
}

type Parrots implements Animal & HomeBody {
  repeatsWords: [String]
}

type Human implements HomeBody {
  id: ID!
  name: String! @search(by: [hash])
}
query {
  queryHome @cascade {
    id
    address
    members @filter(filter: {name: {eq: "Snoopy"}}) {
      id
    }
  } 
}

If I understand correctly there will be no auto generated queryHomeMember so I could not do a better query without cascade going from the reverse direction:

query {
  queryHomeMember(filter: {name: {eq: "Snoopy"}}) {
    hasHome {
      id
      address
      members {
        name
      }
    }
  }
}

But I would have to know that it was a dog and do this query:

query {
  queryDog(filter: {name: {eq: "Snoopy"}}) {
    hasHome {
      id
      address
      members {
        name
      }
    }
  }
}

This does not work in every use case: Vet finding a tag with name and trying to find what animal and what owner it belonged to in a single query.

Relevant: https://github.com/graphql/graphql-spec/blob/master/rfcs/InputUnion.md

1 Like

Thanks.

Seems the approach we are taking at present for having unions as inputs is actually one of the discussed approaches and is the most favoured one. But, will have to wait for the discussion to settle and the final RFC to come up for the draft GrapQL spec to find out what is the final take on this.

Updated the RFC to also include an example for that case.
I will try my best to get it all in one PR in master, but because of the possibility of fragments being present in auth queries, this may come out as a separate PR in master. Although, everything should be finally available in the v20.11.0 release.

I would answer the rest of your questions after I discuss internally whether we should support @hasInverse on union too.

For this, could you provide an example schema and query?

I tried above, so let me try again. So the tag is found so we know only the name, not what type, but only what union. We know it belongs to a type that implements HomeBody and Animal (using my schema above) either a Dog or a Parrot. But not wanting to search Dogs, and Parrots (this could be many many more) for the same name, they want a way to search for the name once and get the answer. I figure that they would either have to search for Animal by name and find the HomeBody.hasHome predicate.

So… not exactly sure how the GQL query would look with this schema, or even if this would be the best schema for this situation. That is the closest example I could work up building upon your schema with Animals and Humans.

I am thinking the DQL would look like:

node(func: eq(Animal.name, "Snoopy")) @cascade {
  HomeBody.hasHome {
    Home.address
    Home.members {
      Human.name
    }
  }
}

My brain is starting to hurt switching from DQL to GQL back and forth, I am starting to write @filter() in GQL and try sometimes to write filter: {} in DQL

1 Like

Nervous Laugh GIF by memecandy

That I can relate.

First I got lost in Documentation.
And now context switching between DQL & GQL.
Plus once in a while there’s RDF syntax.

dumb dummy GIF

1 Like

Related: Allow @hasInverse on union types