graphile / crystal

🔮 Graphile's Crystal Monorepo; home to Grafast, PostGraphile, pg-introspection, pg-sql2 and much more!
https://graphile.org/
Other
12.61k stars 571 forks source link

Intended way to return a union like `union Result = User | UsernameTaken | ...` in v5? #1915

Closed dobry-den closed 10 months ago

dobry-den commented 10 months ago

Note: This is a question for grafast / postgraphile v5.

There are no google results for the error message I'm getting (shared later on), so hopefully this issue could at least answer some questions.

Summary

I want a graphql function to return a union of a table row and other data objects. Though this question is general, my concrete use-case is that I want my registerUser() function to return User | UnameTaken | EmailTaken.

I should also note that I have a app_public.user table and postgraphile has already created a User type for it that my code already works with. I'm just trying to add some local UnameTaken and EmailTaken types here for the registration result.

(Traditionally, you would represent these as error codes for the client to check err.code == "UNAME_TAKEN". However, these data objects let us pass along additional, arbitrary information to the client.)

So my graphql declaration looks like this.

input RegisterUserInput {
    uname: String!
    email: String!
}

type UnameTaken {
    message: String!
}

type EmailTaken {
    message: String!
}

union RegisterUserResult = User | UnameTaken | EmailTaken

type RegisterUserPayload {
    result: RegisterUserResult
    query: Query
}

extend type Mutation {
    registerUser(input: RegisterUserInput!): RegisterUserPayload
}

When I try to execute registerUser(), I get this error:

Error occurred during query planning:
Error: The 'RegisterUserResult' interface or union type's first type 'User' expected a plan, however the type 'UnameTaken' (index = 1) did not expect a plan. All types in an interface or union must be in agreement about whether a plan is expected or not.

I've tried various things like register UnameTaken under the plugin's plans property, but I haven't had any luck making progress here.


Here is the full RegisterUserPlugin that sets all of this up:

import { makeExtendSchemaPlugin, gql } from 'postgraphile/utils'
import { access, constant, object } from 'postgraphile/grafast'
import { withPgClientTransaction } from 'postgraphile/@dataplan/pg'

const RegisterUserPlugin = makeExtendSchemaPlugin((build) => {
    const { user } = build.input.pgRegistry.pgResources
    const { executor } = user
    return {
        typeDefs: gql`
            input RegisterUserInput {
                uname: String!
                email: String!
            }

            type UnameTaken {
                message: String
            }

            type EmailTaken {
                message: String
            }

            union RegisterUserResult = User | UnameTaken | EmailTaken

            type RegisterUserPayload {
                result: RegisterUserResult
                query: Query
            }

            extend type Mutation {
                registerUser(input: RegisterUserInput!): RegisterUserPayload
            }
        `,
        plans: {
            Mutation: {
                registerUser2(_, fieldArgs) {
                    const $input = fieldArgs.getRaw('input')

                    const $result = withPgClientTransaction(
                        executor,
                        $input,
                        async (pgClient, input) => {
                            let user
                            try {
                                const { rows } = await pgClient.query({
                                    text: `
                                        INSERT INTO app_public.user (uname)
                                        VALUES ($1)
                                        RETURNING *`,
                                    values: [input.uname],
                                })
                                user = rows[0]
                            } catch (e) {
                                // Check if uname taken
                                if (
                                    (e as any).code === '23505' &&
                                    (e as any).constraint ===
                                        'unique_user_uname'
                                ) {
                                    return {
                                        __typename: 'UnameTaken',
                                        message: '...',
                                    }
                                } // check if email taken
                                else if (
                                    (e as any).code === '23505' &&
                                    (e as any).constraint ===
                                        'unique_user_email'
                                ) {
                                    return {
                                        __typename: 'EmailTaken',
                                        message: '...',
                                    }

                                }
                                throw e
                            }

                            await pgClient.query({
                                text: `
                                    INSERT INTO app_secret.user_emails(user_id, email)
                                    VALUES ($1, $2)
                                `,
                                values: [user.id, input.email],
                            })

                            await sendEmail(input.email, 'Welcome!')

                            return user
                        },
                    )

                    return object({ result: $result })
                },
            },

            RegisterUserPayload: {
                result($data) {
                    return $data.get('result')
                },
                query($data) {
                    return constant(true)
                },
            },
        },
    }
})

I know it's not fully correct. It doesn't rollback the transaction (I don't even think you can do that inside the withTransaction helper). But it's a minimal code example to give us something concrete to work with.

Thanks for any help.

benjie commented 10 months ago

I'm going to turn this question into tests and documentation.

benjie commented 10 months ago

Implemented with example in https://github.com/graphile/crystal/pull/1917

I took the liberty of reworking your plugin and schema somewhat; but here's a plugin similar to yours that works with the latest crystal (not yet released):

import { withPgClient } from "@dataplan/pg";
import {
  access,
  constant,
  ExecutableStep,
  list,
  object,
  ObjectStep,
  polymorphicBranch,
} from "grafast";
import { DatabaseError } from "pg";
import { gql, makeExtendSchemaPlugin } from "postgraphile/utils";

export const RegisterUserPlugin = makeExtendSchemaPlugin((build) => {
  const { users } = build.input.pgRegistry.pgResources;
  const { executor } = users;
  return {
    typeDefs: gql`
      extend type Mutation {
        registerUser(input: RegisterUserInput!): RegisterUserPayload
      }

      input RegisterUserInput {
        username: String!
        email: String!
      }

      type RegisterUserPayload {
        result: RegisterUserResult
        query: Query
      }

      union RegisterUserResult = User | UsernameConflict | EmailAddressConflict

      type UsernameConflict {
        message: String!
        username: String!
      }

      type EmailAddressConflict {
        message: String!
        email: String!
      }
    `,
    plans: {
      Mutation: {
        registerUser(_, { $input: { $username, $email } }) {
          const $result = withPgClient(
            executor,
            list([$username, $email]),
            async (pgClient, [username, email]) => {
              try {
                return await pgClient.withTransaction(async (pgClient) => {
                  const {
                    rows: [user],
                  } = await pgClient.query<{
                    id: string;
                    username: string;
                  }>({
                    text: `
                      insert into app_public.users (username)
                      values ($1)
                      returning *`,
                    values: [username],
                  });

                  await pgClient.query({
                    text: `
                      insert into app_public.user_emails(user_id, email)
                      values ($1, $2)`,
                    values: [user.id, email],
                  });

                  await sendEmail(email as string, "Welcome!");

                  return { id: user.id };
                });
              } catch (e) {
                if (e instanceof DatabaseError && e.code === "23505") {
                  if (e.constraint === "unique_user_username") {
                    return {
                      __typename: "UsernameConflict",
                      message: `The username '${username}' is already in use`,
                      username,
                    };
                  } else if (e.constraint === "unique_user_email") {
                    return {
                      __typename: "EmailAddressConflict",
                      message: `The email address '${email}' is already in use`,
                      email,
                    };
                  }
                }
                throw e;
              }
            },
          );

          return object({ result: $result });
        },
      },

      RegisterUserPayload: {
        __assertStep: ObjectStep,
        result($data: ObjectStep) {
          const $result = $data.get("result");
          return polymorphicBranch($result, {
            UsernameConflict: { /* defaults */ },
            EmailAddressConflict: { /* defaults */ },
            User: {
              match(obj) {
                return obj.id != null;
              },
              plan($obj) {
                const $id = access($obj, "id");
                return users.get({ id: $id });
              },
            },
          });
        },
        query() {
          // The `Query` type just needs any truthy value.
          return constant(true);
        },
      },

      UsernameConflict: {
        // Since User expects a step, our types must also expect a step. We
        // don't care what the step is though.
        __assertStep: ExecutableStep,
      },
      EmailAddressConflict: {
        __assertStep: ExecutableStep,
      },
    },
  };
});

async function sendEmail(_email: string, _message: string) {
  /*
    Write your email-sending logic here. Note that we recommend you enqueue a
    job to send the email rather than sending it directly; if you don't already
    have a job queue then check out https://worker.graphile.org
  */
}