Mapping Union Types

GraphQL has the GraphQLUnionType, which allows for polymorphic fields. After resolving the values for these fields, you must also resolve the type, to figure out which of the base types it ends up being. Similar to the GraphQLObjectType, Join Monster maps these to a SQL table. However, it's more likely that these will be VIEWs or derived tables.

This table must also have a key, which can be composite. There is one additional requirement for this table. The table must provide all the columns with the same names as the tables for each type it contains. For example, suppose you have a GraphQLUnionType of types Foo and Bar. If table foo has columns a and b and table bar has columns a and c, the table for the union type must provide columns a, b, and c.

const FooBar = new GraphQLUnionType({
  name: 'FooBar',
  types: [Foo, Bar],
  extensions: {
    joinMonster: {
      // a derived table that combines the two tables into one via a UNION
      sqlTable: `(
        SELECT
          a,
          b,
          NULL as c
        FROM foo
        UNION
        SELECT
          a,
          NULL as b,
          c
        FROM bar
      )`,
      // specify unique key
      uniqueKey: 'a'
    }
  },
  resolveType: () => {
    /* TODO */
  }
})

Inserting NULL in place of differing columns can reconcile two incongruous tables.

We assumed a was unique across both tables. This will not always be the case. Let's see how one might handle this in our example schema. Suppose we want to get a list of everything a user has written. So we create an Authored type to union Post and Comment.

Let's not assume the id columns are not unique once unioned together. We could add a computed column to guarantee a unique value. There is, however, something more useful we can do.

const Authored = new GraphQLUnionType({
  name: 'Authored',
  types: () => [Comment, Post],
  extensions: {
    joinMonster: {
      sqlTable: `(
        SELECT
          id,
          body,
          author_id,
          NULL AS post_id,  -- post has no post_id, so fill that in with NULL
          'Post' AS "$type"
        FROM posts
        UNION ALL
        SELECT
          id,
          body,
          author_id,
          post_id,
          'Comment' AS "$type"
        FROM comments
      )`,
      // the combination of `id` and `$type` will always be unique
      uniqueKey: ['id', '$type']
    }
  },
  resolveType: obj => {
    /* TODO */
  }
})

We can introduce a new column "$type" to not only provide a basis for a unique (composite) key, but also to disambiguate which object type each object should resolve to. This will address the uniqueness problem, but the actual hydrated data will not contain the "$type" (as it was not requested in the GraphQL query), so we cannot yet reference it in resolveType. Join Monster provides an optional alwaysFetch property which forces that column to always appear in the hydrated data. You can use this to implement resolveType.

const Authored = new GraphQLUnionType({
  name: 'Authored',
  types: () => [Comment, Post],
  extensions: {
    joinMonster: {
      sqlTable: `(...)`,
      uniqueKey: ['id', '$type'],
      // tells join monster to always fetch the $type in the hydrated data
      alwaysFetch: '$type'
    }
  },
  // easily gleaned from the column we added in SQL
  resolveType: obj => obj.$type
})

Interface Types

The Post and Comment types share the body and author_id fields. It is also feasible to implement Authored as a GraphQLInterfaceType. This is GraphQL's other option for polymorphic types, but it has shared fields which must appear on all types that claim to implement that interface. Join Monster treats these nearly the same. The difference is that you must decorate the fields on the interface type defintion.

const Authored = new GraphQLInterfaceType({
  name: 'Authored',
  extensions: {
    joinMonster: {
      sqlTable: `(
        SELECT
          id,
          body,
          author_id,
          NULL AS post_id,
          'Post' AS "$type"
        FROM posts
        UNION ALL
        SELECT
          id,
          body,
          author_id,
          post_id,
          'Comment' AS "$type"
        FROM comments
      )`,
      // the combination of `id` and `$type` will always be unique
      uniqueKey: ['id', '$type'],
      alwaysFetch: '$type'
    }
  },
  fields: () => ({
    id: {
      // still assumed to have the same column name as the field name
      type: GraphQLInt
    },
    body: {
      type: GraphQLString
    },
    authorId: {
      type: GraphQLInt,
      extensions: {
        joinMonster: {
          // but this column name is different
          sqlColumn: 'author_id'
        }
      }
    }
  }),
  resolveType: obj => obj.$type
})

Placing on a Field

These types are used just as any other GraphQLObjectType that is mapped to a table. That is, if it appears as a child field on another GraphQLObjectType which is also mapped to a table, Join Monster expects a JOIN condition(s) or batch keys.

const User = new GraphQLObjectType({
  // ...
  fields: () => ({
    // ...
    writtenMaterial: {
      type: new GraphQLList(Authored),
      extensions: {
        joinMonster: {
          orderBy: 'id',
          // how to join on the derived table
          sqlJoin: (userTable, unionTable) =>
            `${userTable}.id = ${unionTable}.author_id`
          // or we could have done it in a batch
          // sqlBatch: {
          //   thisKey: 'author_id',
          //   parentKey: 'id'
          // }
        }
      }
    }
  })
})

So now we can handle queries like this!

{
  user(id: 1) {
    writtenMaterial {
      __typename
      id
      body
      authorId
      ... on Comment {
        postId
        likers {
          fullName
        }
      }
    }
  }
}

Relay's Node Interface

Join Monster provides a vastly more convenient helper function for Relay's Node Interface. See this page in the docs.