Skip to content

Instantly share code, notes, and snippets.

@benjie
Last active December 4, 2023 14:41
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save benjie/e45540ad25ce9c33c2a1552da38adb91 to your computer and use it in GitHub Desktop.
Save benjie/e45540ad25ce9c33c2a1552da38adb91 to your computer and use it in GitHub Desktop.
Exploring how `oneOf` could work in a GraphQL schema

oneOf exploration

This document is a work-in-progress exploration of how the "oneOf" solution to input polymorphism might work within a GraphQL schema.

Base schema

For the examples below, we'll be using the following shared types using existing GraphQL syntax:

type Person {
  id: ID!
  name: String!
}

type Cat {
  id: ID!
  name: String!
  owner: Person
  numberOfLives: Int
}

input CatInput {
  name: String!
  numberOfLives: Int
}

type Dog {
  id: ID!
  name: String!
  owner: Person
  breed: String
}

input DogInput {
  name: String!
  breed: String
}

"""
Some people keep cats and dogs as pets; some people keep colonies of creatures.
"""
type Colony {
  id: ID!
  colonyType: ColonyType
  owner: Person
}

enum ColonyType {
  WORM
  ANT
  BEE
}

Solution A

In solution A, the input is a single-key map from a string to the associated concrete input type (input object type, scalar, enum, etc), for example { "cat": { "name": "Felix", "numberOfLives": 9 } }.

The following syntactic variants all have identical meanings; they're just different ways of expressing this concept through SDL.

(Note the scalars integer and rational have been added to demonstrate flexibility and lack of ambiguity; it's not expected that many people keep numbers as pets.)

input PetInput @oneOf {
  cat: CatInput
  dog: DogInput
  colony: ColonyType
  integer: Int
  rational: Float
}
inputUnion PetInput  {
  cat: CatInput
  dog: DogInput
  colony: ColonyType
  integer: Int
  rational: Float
}
inputUnion PetInput =
  | { cat: CatInput }
  | { dog: DogInput }
  | { colony: ColonyType }
  | { integer: Int }
  | { rational: Float }
inputUnion PetInput =
  | { cat: CatInput! }
  | { dog: DogInput! }
  | { colony: ColonyType! }
  | { integer: Int! }
  | { rational: Float! }

Solution B

This solution is a more restricted form of Solution A, where the map key must be the exact type name of the input, for example { "CatInput": { "name": "Felix", "numberOfLives": 9 } }.

This enables all the syntaxes above (although they'd look a lot more redundant), plus this further more beautiful syntax:

inputUnion PetInput =
  | CatInput
  | DogInput
  | ColonyType
  | Int
  | Float

(This variant is slightly less flexible, in that you cannot have the same type referenced in two different ways (e.g. using String twice), but this restriction may be a benefit in terms of clarity.)

Solution A usage

Imagine that we had the following mutation:

extend type Mutation {
  addPets(pets: [PetInput!]!): [Pet!]
}

In all of the Solution A cases, we could issue the mutation via this same operation document:

mutation($pets: [PetInput!]!) {
  addPets(pets: $pets) {
    id
  }
}

and for all of them, the variables would be the same:

{
  "pets": [
    { "cat": { "name": "Felix", "numberOfLives": 9 } },
    { "dog": { "name": "Buster" } },
    { "colony": "WORM" },
    { "integer": "42" },
    { "rational": "3.141592653589793" }
  ]
}

Solution B usage

Same mutation and operation document as Solution A, but the JSON would be more explicit:

{
  "pets": [
    { "CatInput": { "name": "Felix", "numberOfLives": 9 } },
    { "DogInput": { "name": "Buster" } },
    { "ColonyType": "WORM" },
    { "Int": "42" },
    { "Float": "3.141592653589793" }
  ]
}

Nesting

You could nest oneOf unions; e.g.

inputUnion MediaInput =
  | BookInput
  | DVDInput

inputUnion SourceInput =
  | LibraryInput
  | OnlineVideoRentalInput

input BookInput {
  title: String!
  numberOfPages: Int
  availableFrom: [SourceInput!]!
}

input DVDInput {
  title: String!
  durationInMinutes: Float
  availableFrom: [SourceInput!]!
}

input LibraryInput {
  name: String!
  address: Address
}

input OnlineVideoRentalInput {
  name: String!
  website: String!
}

Input of a MediaInput might look like:

{
  "DVDInput": {
    "title": "The Matrix",
    "durationInMinutes": 150.3,
    "availableFrom": [
      {
        "LibraryInput": {
          "name": "Mytown Library"
        }
      },
      {
        "OnlineVideoRentalInput": {
          "name": "1-line Vidz",
          "website": "http://example.com/1-line"
        }
      }
    ]
  }
}

Output oneOf - Solution A

The oneOf principle could also be applied to outputs; here are some equivalent syntaxes:

type Pet @oneOf {
  cat: Cat
  dog: Dog
  colony: ColonyType
  integer: Int
  rational: Float
}

(Note: I've used the union keyword here, overloading union with two forms, but we could just as easily use a different keyword such as oneOf.)

union Pet {
  cat: Cat
  dog: Dog
  colony: ColonyType
  integer: Int
  rational: Float
}
union Pet =
  | { cat: Cat }
  | { dog: Dog }
  | { colony: ColonyType }
  | { integer: Int }
  | { rational: Float }
union Pet =
  | { cat: Cat! }
  | { dog: Dog! }
  | { colony: ColonyType! }
  | { integer: Int! }
  | { rational: Float! }

For example, if you had the GraphQL schema:

extend type Query {
  pets: [Pet!]
}

You could issue this query:

{
  __typename
  pets {
    __typename
    cat {
      __typename
      name
      numberOfLives
    }
    dog {
      __typename
      name
    }
    colony
    integer
    rational
  }
}

and you might get a response such as:

{
  "data": {
    "__typename": "Query",
    "pets": [
      {
        "__typename": "Pet",
        "cat": {
          "__typename": "Cat",
          "name": "Felix",
          "numberOfLives": 9
        }
      },
      {
        "__typename": "Pet",
        "dog": {
          "__typename": "Dog",
          "name": "Buster"
        }
      },
      { "__typename": "Pet", "colony": "WORM" },
      { "__typename": "Pet", "integer": "42" },
      { "__typename": "Pet", "rational": "3.141592653589793" }
    ]
  }
}

💡 It might seem weird at first that __typename is returning Pet which we don't think of as a concrete type. However, it actually is a concrete type with known fields and the special semantic that exactly one non-introspection field is non-null. Effectively Pet is very similar to a regular object type.

This has one drawback in that if the relevant type was not queried, we do not know what it was, whereas with regular unions we could at least get its type name. This is more relevant to debugging than it is to application users.

In summary, the main differences between querying oneOf unions vs regular unions are:

  • oneOf unions would not require fragments
  • oneOf results follow the "nested" / "wrapper object" pattern
  • a __typename directly in the selection set for the oneOf would return the oneOf's type name rather than the concrete type (see 💡 above).

Output oneOf - Solution B

As Solution A, but with the field name restriction. Here we'd need to use a new keyword to avoid syntax conflict with the union type.

oneOf Pet =
  | Cat
  | Dog
  | ColonyType
  | Int
  | Float
{
  "data": {
    "__typename": "Query",
    "pets": [
      {
        "__typename": "Pet",
        "Cat": {
          "__typename": "Cat",
          "name": "Felix",
          "numberOfLives": 9
        }
      },
      {
        "__typename": "Pet",
        "Dog": {
          "__typename": "Dog",
          "name": "Buster"
        }
      },
      { "__typename": "Pet", "Colony": "WORM" },
      { "__typename": "Pet", "Int": "42" },
      { "__typename": "Pet", "Float": "3.141592653589793" }
    ]
  }
}

Arguments

oneOf could also be a way of reducing the number of "fetcher" fields required in your GraphQL schema; for example, an existing GraphQL schema might have person-finder fields such as:

extend type Query {
  personById(id: ID!): Person
  personByUsername(username: String!): Person
  personByEmail(email: String!): Person
  parentOfChild(childId: ID!): Person
  departmentHead(organizationId: ID!, department: Department!): Person
}

This could be simplified to a single person fetcher field:

input OrganizationAndDepartmentInput {
  organizationId: ID!
  department: Department!
}

extend type Query {
  person(
    id: ID
    username: String
    email: String
    childId: ID
    organizationAndDepartment: OrganizationAndDepartmentInput
  ): Person
}

However, currently this loses type safety on the arguments and punts the problem of validation to the resolver.

Using a "oneOf" approach could restore this type safety, and could be applied retro-actively to schemas of the above shape and move the validation errors from the execution phase to the validation phase. The following syntaxes are equivalent:

extend type Query {
  person(
    id: ID
    username: String
    email: String
    childId: ID
    organizationAndDepartment: OrganizationAndDepartmentInput
  ): Person @oneArgument
}
extend type Query {
  person(
    @oneOf

    id: ID
    username: String
    email: String
    childId: ID
    organizationAndDepartment: OrganizationAndDepartmentInput
  ): Person
}
extend type Query {
  person(
    id: ID @oneOf
    username: String @oneOf
    email: String @oneOf
    childId: ID @oneOf
    organizationAndDepartment: OrganizationAndDepartmentInput @oneOf
  ): Person
}
extend type Query {
  person(
    | { id: ID }
    | { username: String }
    | { email: String }
    | { childId: ID }
    | { organizationAndDepartment: OrganizationAndDepartmentInput }
  ): Person
}
inputUnion PersonFinderInput =
  | { id: ID }
  | { username: String }
  | { email: String }
  | { childId: ID }
  | { organizationAndDepartment: OrganizationAndDepartmentInput }

extend type Query {
  person(PersonFinderInput): Person
}
@jchristman-plex
Copy link

I personally love this syntax and it would syntactically solve a search functionality I want to implement:

input OwnerFinderInput @oneOf {
  OR: [OwnerFinderInput!]
  AND: [OwnerFinderInput!]
  cat: CatInput
  dog: DogInput
}

This would allow for queries like:

{ 
   getOwner(OwnerFinderInput): [Person]
}

The following input would find all cat owners named "bob" who also have a labrador retriever OR any owner with a dog named "fluffy"

{
  "OR": [
    {
      "AND": [
        {
          "cat": {
            "owner": "bob"
          }
        },
        {
          "dog": {
            "breed": "labrador retriever"
          }
        }
      ]
    },
    {
      "dog": {
        "name": "fluffy"
      }
    }
  ]
}

@benjie
Copy link
Author

benjie commented Jun 17, 2020

You can follow along with the proposal here: graphql/graphql-spec#733

@dmallon1
Copy link

dmallon1 commented Mar 2, 2022

I personally love this syntax and it would syntactically solve a search functionality I want to implement:

input OwnerFinderInput @oneOf {
  OR: [OwnerFinderInput!]
  AND: [OwnerFinderInput!]
  cat: CatInput
  dog: DogInput
}

...

@jchristman-plex You ever figure out a workaround or a way to do it differently?

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