jakeblaxon / graphql-join

MIT License
2 stars 0 forks source link

GraphQL-Join

Join types together in your schema declaratively with SDL.

:warning: This library is new and still evolving. Breaking changes will occur. Use it at your own risk.

Getting Started

npm install graphql-join graphql

Once these packages are installed, you can create a GraphQL-Join transform and wrap your original schema with it to create the gateway schema. You can view the demo that goes along with the example below.

Example

Say you have a GraphQL schema that looks like the following:

type Query {
  getAuthors(ids: [String]): [Author]
  getBooks(ids: [String]): [Book]
}
type Author {
  id: String!
  name: String
}
type Book {
  id: String!
  title: String
  authorId: String
}

The Book type has a reference to its Author, but only in the form of a String. We want to add a new field Book.author that points to the actual object, like so:

extend type Book {
  author: Author
}

Normally you can do this in the backend, but what happens if you don't have access to the original data source? For example, this could be a third party schema, or it could be automatically generated with something like Hasura. You will instead have to join these types together in a gateway, making batched subqueries to the original schema:

subquery sequence diagram

You can implement this pattern in code, but it becomes tedious and hard to read the more joins you add. It's also error-prone and not type-safe. As the underlying schema changes, old resolver logic can quietly become obsolete and begin to fail.

GraphQL-Join aims to solve these issues by abstracting away the details. All you need to provide is an SDL string that describes the subquery to make. The following schema transform implements this pattern declaratively, and is more readable:

import GraphQLJoinTransform from 'graphql-join';
import {wrapSchema} from '@graphql-tools/wrap';

const graphqlJoinTransform = new GraphQLJoinTransform({
  typeDefs: `
    extend type Book {
      author: Author
    }
  `,
  resolvers: {
    Book: {
      author: `getAuthors(ids: $authorId) { authorId: id }`,
    },
  },
});

const gatewaySchema = wrapSchema({
  schema: originalSchema,
  transforms: [graphqlJoinTransform],
});

The typeDefs field describes the overall joins you'd like to add, whereas the resolvers field details how to resolve each join. You can see at Book.author, we added SDL describing the batched subquery to make to resolve authors for books. Let's look at some of the special syntax:

GraphQL-Join doesn't call this query exactly as written. It simply uses the information within it to generate a custom query for each request. Behind the scenes, GraphQL-Join will strip the aliases and add the user requested fields to the selection set, to get all the required information in one call.

Joining on Lists

GraphQL-Join supports one-to-one, one-to-many, and many-to-many relationships. In reality, Book to Author is not one-to-one but many-to-many. Let's see how this could look:

type Book {
  id: String!
  title: String
  authorIds: [String]
}

To join them together, we can use the following config:

{
  typeDefs: `
    extend type Book {
      authors: [Author!]!
    }
  `,
  resolvers: {
    Book: {
      authors: `getAuthors(ids: $authorIds) { authorIds: id }`
    }
  }
}

The query looks very similar to the one-to-one example, but there are a few key differences:

You can also add a symmetrical relation to your config like the following:

{
  typeDefs: `
    extend type Author {
      books: [Book!]!
    }
    extend type Book {
      authors: [Author!]!
    }
  `,
  resolvers: {
    Author: {
      books: `getBooksByAuthorIds(ids: $id) { id: authorIds }`
    },
    Book: {
      authors: `getAuthors(ids: $authorIds) { authorIds: id }`
    }
  }
}

Note that in this case we have to call a new query getBooksByAuthorIds because we don't have access to book ids in Author.

As a final note, if you are going to join on a list, then the list can only contain scalar values, and you can only use this one field to join on. GraphQL-Join will not let you use more than one selection if the selection is a list type. This is because it's unclear how to match in this case, as there are multiple options that lead to different results.

Custom Parameters

You can add custom parameters to your fields that can be passed through to the subquery. To do this, simply define the parameters in your typeDefs, and use the parameters by name as variables in your query:

{
  typeDefs: `
    extend type Author {
      books(publishedAfter: Int!): [Book!]!
    }
  `,
  resolvers: {
    Author: {
      books: `
        getBooksByAuthorIds(
          ids: $id,
          publicationYearGreaterThan: $publishedAfter
        ) { 
          id: authorIds
        }`
    }
  }
}

Now whatever the user passes in as the publishedAfter parameter for Author.books will get passed to the subquery as the value of the $publishedAfter variable.

All custom parameters that you use must be non-nullable, to ensure that the subquery is always valid. You may specify default values in the typeDefs, however. Try to use custom parameter names that don't conflict with field names in the parent type, or else those parent fields will be unavailable to use in the subquery. The variable corresponding to the conflicting parameter/field name will always be set to the parameter value in this case.

Unbatched Queries

GraphQL-Join supports batched queries by default, but in certain cases it may not be possible to join with a batched query because the parent and child don't share any common key fields to join on. In this situation, you can make "unbatched" queries (one query per parent) to join parents to children. To specify this, simply replace the selection set with an @unbatched directive on the query:

resolvers: {
  Book: {
    author: `getAuthor(id: $authorId) @unbatched`
  }
}

The $authorId parameter will be set to whatever value the parent.authorId is, because there is only one parent in this case. There is no selection set here because the Book.author field will be set to whatever the unbatched query returns, therefore making a key mapping unnecessary.

:warning: Unbatched queries should be avoided if at all possible because they result in the n+1 problem. Each parent object will make its own network call, potentially congesting the network and increasing delay times.

Advanced

GraphQL-Join is built on top of the @graphql-tools library, currently using major version 7. It specifically uses the batchDelegateToSchema, delegateToSchema, and stitchSchemas methods to create the resolver implementations under the hood. Since there are breaking changes in these methods across major versions, GraphQL-Join allows you to specify which instances of these methods it should use, so that it may work with other libraries using a specific version of @graphql-tools. You can specify this in the GraphQLJoinTransform config as follows:

new GraphQLJoinTransform({
  typeDefs: ...
  resolvers: ...
  graphqlTools: {
    batchDelegateToSchema: batchDelegateToSchemaV6,
    delegateToSchema: delegateToSchemaV6,
    stitchSchemas: stitchSchemasV6,
  }
})

Benefits

Using GraphQL-Join has several benefits:

Contributing

GraphQL-Join is a new library and is still evolving. If you have an idea on how to improve it, please open an issue on GitHub, or fork this library and create a pull request.