eddeee888 / graphql-code-generator-plugins

List of GraphQL Code Generator plugins that complements the official plugins.
MIT License
51 stars 12 forks source link

[DX] Resolvers chains working by default #225

Closed konhi closed 6 months ago

konhi commented 8 months ago

Is your feature request related to a problem? Please describe. The way this plugin generates codes naturally makes users do resolvers chaining. This is a standard GraphQL practice documented by Apollo Docs > Server > Resolvers > Resolver Chains.

When using this plugin for the first time, it was unexpected to me that this wasn't supported by default. I spent few hours trying to look for a solution.

Describe the solution you'd like Enable support for resolver chaining by making this config default. We can also just update readme of this plugin and this guide: https://the-guild.dev/graphql/codegen/docs/guides/graphql-server-apollo-yoga-with-server-preset

const config: CodegenConfig = {
  schema: '**/schema.graphql',
  generates: {
    'src/schema': defineConfig({
      typesPluginsConfig: {
        /* This is required for resolvers chaining */
        useIndexSignature: true,
        defaultMapper: 'Partial<{T}>',
      }
    })
  }
}

Additional context

arjunyel commented 7 months ago

if you are using resolver chains then you will need nested partials using utility-types https://the-guild.dev/graphql/codegen/plugins/typescript/typescript-resolvers#allow-deep-partial-with-utility-types

const config: CodegenConfig = {
 schema: "**/schema.graphql",
 generates: {
  "src/schema": defineConfig({
   add: {
    "./types.generated.ts": {
     content: "import type { DeepPartial } from 'utility-types';",
    },
   },
   typesPluginsConfig: {
    defaultMapper: "DeepPartial<{T}>",
   },
  }),
 },
};
eddeee888 commented 7 months ago

Hello 👋 Thanks for creating this issue. I think there's definitely something we can work out to make the usage clearer. 🙂

For context, this plugin is created to have first-class support the mappers way enabling resolver chaining for the following benefits:

1. Predictability and runtime safety when using a mapper - or not

By default, codegen matches resolver's TS types with GraphQL field output type. This eliminates the chance where someone forgets to return null into a non-nullable GraphQL field - which is possible with Partial or DeepPartial - and cause a runtime error. Furthermore:

2. Consistent baseline object when multiple resolvers return the same type

Consider the following schema:

type Cat {
  owner: Person!
}

type Dog {
  owner: Person!
}

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

With Partial or DeepPartial, it's easy to accidentally return the inconsistent Person object in Cat.owner and Dog.owner resolvers, making it much easier to hit runtime errors, especially in bigger apps e.g.

const resolvers: Resolvers = {
  Cat: {
    owner: () => ({ id: "100" }) // 👈 object with only `id` can be return here, because `Person` is partial. This will result in runtime error.
  }
  Dog: {
    owner: () => ({ name: "Bart" }) // 👈 object with only `name` can be return here, because `Person` is partial. This will result in runtime error.
  }
}

On the other hand, Person resolvers wouldn't know what's being passed in, making it hard to code and may cause runtime errors:

const resolvers: Resolvers = {
  // Same Cat/Dog resolvers as before
  Person: {
    id: ({ id }) => id // 👈 This will trigger type error because `id` is nullable from using `Partial`
    // 👈 No `name` resolver... which means it'd error if clients query for `Cat.owner.name` because `Person.name` is non-nullable in the Graph, but we don't handle the nullable case
  }
}

For these reasons, Partial and DeepPartial mappers is supported via your mentioned config options, but not by default for this plugin. I'll add some notes to the guides to clarify the reasoning 🙂


For context, here's the file convention of using mappers when using this plugin: https://the-guild.dev/graphql/codegen/docs/guides/graphql-server-apollo-yoga-with-server-preset#adding-mappers

arjunyel commented 7 months ago

@eddeee888 sorry how do you do resolver chaining without DeepPartial? Do you have a code example somewhere? Without DeepPartial you get a typescript error that a field is missing even if you resolve it somewhere else

eddeee888 commented 7 months ago

Hi @arjunyel , sure!

I've set up a small GraphQL server with some mocked data here: https://github.com/eddeee888/graphql-server-template

Let's look at the related part of the schema extracted from that repo:

# book/schema.graphql
type Book {
  id: ID!
  isbn: String!
}

# user/schema.graphql
extend type Query {
  user(id: ID!): User
}
type User {
  id: ID!
  fullName: String!
  booksRead: [Book!]!
}

In Query.user resolver, we don't want to implement with booksRead as we want to let User.booksRead handle that logic.


Here's how we can do with this preset:

  1. Declare a User mapper in user/schema.mappers.ts. This mapper is the minimal shape that can be used to resolve any User fields:
// user/schema.mappers.ts

// Preset detects a mapper by the type name `User` (GraphQL type) + `Mapper` (The customisable suffix)
export type UserMapper = { 
  id: string;
  firstName: string;
  lastName: string;
};

What this means is we can now pass this mapper object into any resolver that is supposed to return a GraphQL User node. Our Query.user may look something like this:

// user/resolvers/Query/user.ts
export const user: NonNullable<QueryResolvers["user"]> = () => {
  // We must now return a consistent `UserMapper` into GraphQL `User` node
  // Without the UserMapper, we must return `{ id: string, fullName: string, booksRead: Book[] }`
  return {
    id: "1",
    firstName: "Bart",
    lastName: "Simpson"
  }
};

And we can resolve User.booksRead like this:

// user/resolvers/User.ts 
export const User: UserResolvers = {
  booksRead: async (parent, _ , { booksAPI }) => { // `parent` is `UserMapper`
    // 👇 Server preset would generate `User.booksRead` and tell us _why_ this is generated
    /* User.booksRead resolver is requied because User.booksRead exists but UserMapper.booksRead does not */

    /* Do booksRead resolver logic here e.g. */
    const booksRead = await booksAPI.getBooksReadByUserId({ userId: parent.id });
    return booksRead; 
  },
  fullName: (parent) => {
    //👇 Server preset would generate `User.fullName` resolver and tell us _why_ we this is generated
    /* User.fullName resolver is required because User.fullName exists but UserMapper.fullName does not */
    return `${parent.firstName} ${parent.lastName}`;
  },
};

Mapper is a concept available in the codegen typescript-resolvers plugin. The server preset adds auto resolver-level generation to eliminate runtime error, which is one of the benefits over just using the base typescript-resolvers plugin


You can find the full example in the mentioned repo with these main files:

eddeee888 commented 7 months ago

Let me know if this doesn't answer your question @arjunyel ! Happy to chat more. If we are happy with this discussion, I'll close this issue in 3 days and update the docs

arjunyel commented 7 months ago

@eddeee888 makes total sense, you are the biggest legend in history!