prisma-binding icon indicating copy to clipboard operation
prisma-binding copied to clipboard

Manipulate resolver's info argument to optimize database access

Open gcangussu opened this issue 7 years ago • 10 comments

The problem

Let's say we have this schema for Prisma:

type Company {
  id: ID! @unique
  name: String!
  users: [User!]!
}

type User {
  id: ID! @unique
  name: String!
  company: Company!
}

And this application schema:

# import User from "./generated/prisma.graphql"

type SignupResponse {
  token: String!
  user: User!
}

type Mutation {
  signup(companyName: String!, userName: String!): SignupResponse!
}

The resolvers of this application then could be:

import { getToken } from './utils';

export default {
  Mutation: {
    signup: async (parent, args, ctx, info) => {
      const user = await ctx.db.mutation.createUser({
        data: {
          name: args.userName,
          company: {
            create: { name: args.companyName },
          },
        },
      });
      
      return {
        user,
        token: getToken(user),
      };
    }
  }
};

This looks right for most programmers, but it will lead to errors in queries for signup that require the user.company in the response.

mutation {
  signupOK: signup(userName: "John", companyName: "ACME") {
    token
    user {
      id
      name
    }
  }
  
  signupError: signup(userName: "Paul", companyName: "Cyberdyne") {
    token
    user {
      id
      name
      company {
        id
        name
      }
    }
  }
}

The error is Cannot return null for non-nullable field User.company. It happens because the ctx.db.mutation.createUser(...) only returns the id and name properties of the user.

Available solution at the time of this writing

Create a resolver for SignupResponse.user. This will work because the graphql server will run the SignupResponse.user resolver after running Mutation.signup, as expected from any graphql server.

import { getToken } from './utils';

export default {
  SignupResponse: {
    user: async ({ user: { id } }, args, ctx, info) =>
      ctx.db.query.user({ where: { id } }, info),
  },

  Mutation: {
    signup: async (parent, args, ctx, info) => {...},
  }
};

The disadvantage of this method is that there will be two requests for the database, one for mutation.createUser() and other for query.user(). And this will happen even when it would be not required, such as in the signupOK query.

Proposed solution

Make a tool that enable us to extract data from resolver's info arguments that is usable in prisma-binding's methods.

In our example all the information about what mutation.createUser() must return is present in the signup's info. But we cannot just pass this info to createUser, because it is incompatible. In order to do that we must be able to take only the information about what is being requested for the user.

So, if we can represent the signup info from signupError as

{
  token
  user {
    id
    name
    company {
      id
      name
    }
  }
}

We must be able to extract only the requested data for user field and pass this part to createUser. Using the same representation this would be

{
  id
  name
  company {
    id
    name
  }
}

Let's say this tool is a function named pluck where you specify the field you want on the first argument and pass the info object on the second. Then we could implement signup correctly as:

import { getToken, pluck } from './utils';

export default {
  Mutation: {
    signup: async (parent, args, ctx, info) => {
      const userInfo = pluck('user', info);
      const user = await ctx.db.mutation.createUser({
        data: {
          name: args.userName,
          company: {
            create: { name: args.companyName },
          },
        },
      }, userInfo);
      
      return {
        user,
        token: getToken(user),
      };
    }
  }
};

We could also think abou a pluckIn(), which is like the getIn methods of Immutable.js, that would allow us to do pluckIn(['user', 'company'], info) instead of pluck('company', pluck('user', info)).

Note: I don't think that pluck fixes #86. I think that mutations such as createUser should return the whole User, including other types associated with it. pluck would be an optional optimization to prevent the DB from being requested unneded data.

gcangussu avatar Feb 25 '18 22:02 gcangussu

I was able to implement a working version of this tool, but I couldn't do it without adding more arguments to the pluck call signature:

import { buildInfo } from 'graphql-binding';
import { Operation } from 'graphql-binding/dist/types';
import { FieldNode, GraphQLResolveInfo, GraphQLSchema } from 'graphql';

/**
 * Get the named fields from the selection of info's root field
 */
function getFieldsByName(name: string, info: GraphQLResolveInfo): FieldNode[] {
  const infoField = info.fieldNodes[0];
  const { selectionSet } = infoField;
  if (!selectionSet)
    throw new Error(`Field '${infoField.name.value}' have no selection.`);

  const { selections } = selectionSet;
  const found = selections.filter(
    field => field.kind === 'Field' && field.name.value === name,
  ) as FieldNode[];

  if (found.length > 0) return found;

  throw new Error(
    `Field '${name}' not found in '${infoField.name.value}' selection`,
  );
}

function flatMap<T, U>(
  array: T[],
  mapper: (value: T, index: number, array: T[]) => U[],
): U[] {
  return [].concat(...array.map(mapper));
}

function pluck(
  rootFieldName: string,
  operation: Operation,
  schema: GraphQLSchema,
  fieldName: string,
  info: GraphQLResolveInfo,
  required?: string,
): GraphQLResolveInfo {
  const fields = getFieldsByName(fieldName, info);
  const oldSelections = flatMap(fields, field => field.selectionSet.selections);

  const newInfo = buildInfo(rootFieldName, operation, schema, required);
  const { selectionSet } = newInfo.fieldNodes[0];

  if (required) {
    selectionSet.selections = selectionSet.selections.concat(oldSelections);
  } else {
    selectionSet.selections = oldSelections;
  }

  return newInfo;
}

Nevertheless, it works. So you could do in the latest example:

const userInfo = pluck('createUser', 'mutation', ctx.db.schema, 'user', info);
const user = await ctx.db.mutation.createUser({...}, userInfo);

If you need to enforce the query of a specific field you can, just use the required argument. For example if you must get the user id, user password and its company id you would do:

pluck('createUser', 'mutation', ctx.db.schema, 'user', info, '{ id, password, company { id } }');

This was necessary for my login resolver.

gcangussu avatar Mar 02 '18 18:03 gcangussu

To solve this repetitive pattern

const rootField = 'myRootField';
const operation = 'myOperation';
const newInfo = pluck(rootField, operation, ctx.db.schema, 'myFieldName', info);
const field = await ctx.db[operation][rootField]({...}, newInfo);

We could declare a new interface such as

interface PluckGraphQLResolveInfo {
  info: GraphQLResolveInfo;
  field: string;
  required?: string;
}

And make the prisma bindings methods accept string | GraphQLResolveInfo | PluckGraphQLResolveInfo for its info arguments. Inside the bindings methods the operation, root field and schema are already available, so they could easily make the manipulation.

Then we would be able to do just this

const user = await ctx.db.mutation.createUser({...}, { info, field: 'user' });

Or this if we have required fields to query

const user = await ctx.db.mutation.createUser({...}, {
  info,
  field: 'user',
  required: '{ id, password, company { id } }',
});

gcangussu avatar Mar 02 '18 18:03 gcangussu

Thanks a lot for bringing this up and creating a PR @gcangussu! We'll look into this shortly!

schickling avatar Apr 01 '18 00:04 schickling

Thanks for moving this discussion forward so far! We just found fundamental limitations with this approach, that I describe here: https://github.com/graphql-binding/graphql-binding/issues/80

timsuchanek avatar Apr 08 '18 14:04 timsuchanek

Correct me if I’m wrong, but I think I’ve run into this too.

I’m OK using the solution outlined in the OP for most cases, but I’m stuck on a delete mutation:

type Mutation {
  removeFeed(data: RemoveFeedInput!): RemoveFeedPayload!
}

input RemoveFeedInput {
  id: ID!
}
type RemoveFeedPayload {
  feed: Feed
}

I have a removeFeed resolver which is not passed the original info object.

const feedMutations = {
   async removeFeed(parent, { data }, ctx, info) {
    const { id } = data;
    const userId = getUserId(ctx);
    // Omitted: check that the user owns this feed, throw otherwise

    const feed = await ctx.db.mutation.deleteFeed({ where: { id } });
    return { feed };
  },
};

And I have a field-level resolver for the final payload:

module.exports = {
  Query: ...
  Mutation: ...
  RemoveFeedPayload: {
    feed: async ({ feed: { id } }, args, ctx, info) => {
      return ctx.db.query.feed({ where: { id } }, info);
    },
  },
};

The problem is that the feed has already been deleted by this point, so the field-level resolver returns null. The reason I have my payloads like this is to follow some advice from the Apollo blog on designing mutations. I’m not sure what to do, other than adjust my mutation payloads to match what Prisma sends back.

ry5n avatar Jul 08 '18 02:07 ry5n

@ry5n I've ran into the same problem, the solution was to mark the entry as deleted and adjust our models to ignore entries marked as deleted. We use a timestamp so we can run a task an exclude all entries marked as deleted since some arbitrary time.

gcangussu avatar Jul 12 '18 02:07 gcangussu

@gcangussu Oh, interesting. I had worked around it by doing the actual deletion in the final resolver (removeFeedMutation) but it feels awkward. A batch task might be better.

I wonder what other GraphQL implementations do? It doesn’t seem like a problem exclusive to Prisma, but I haven’t found anyone advocating any design patterns to address it.

ry5n avatar Jul 12 '18 05:07 ry5n

For anyone who needs to use fragments I was able to extend @gcangussu solution to work with fragments. You just need to "copy" fragments from original info into the new info:

export function pluck(
  rootFieldName: string,
  operation: Operation,
  fieldName: string,
  info: GraphQLResolveInfo,
  required?: string,
): GraphQLResolveInfo {
  const fields = getFieldsByName(fieldName, info);
  const oldSelections = flatMap(fields, (field) => field.selectionSet.selections);

  const newInfo = buildInfo(rootFieldName, operation, info.schema, required);
  const { selectionSet } = newInfo.fieldNodes[0];

  selectionSet.selections = required
    ? selectionSet.selections.concat(oldSelections)
    : oldSelections;

  // We need to copy fragments from original info object into the new info object
  const newInfoWithFragments: GraphQLResolveInfo = {
    ...newInfo,
    fragments: info.fragments,
  };

  return newInfoWithFragments;
}

Hope this helps!

nemcek avatar Oct 03 '18 10:10 nemcek

The pluck implementation is cool and should definitely be an official utility function.

zlwu avatar Jan 03 '19 05:01 zlwu

This was a genius, should be an official utility or npm library for this! 💪 I assume that doesn't exist yet?

To summarise for anybody wanting a quick version converted form TypeScript to Javascript, including @nemcek improvments.

// ./utils/pluckInfo.js
import { buildInfo } from 'graphql-binding'

function getFieldsByName(name, info) {
  const infoField = info.fieldNodes[0]
  const { selectionSet } = infoField
  if (!selectionSet)
    throw new Error(`Field '${infoField.name.value}' have no selection.`)

  const { selections } = selectionSet
  const found = selections.filter(
    field => field.kind === 'Field' && field.name.value === name,
  )

  if (found.length > 0) return found

  throw new Error(
    `Field '${name}' not found in '${infoField.name.value}' selection`,
  )
}

function flatMap (array, mapper) {
  return [].concat(...array.map(mapper))
}

export default function pluckInfo(
  rootFieldName,
  operation,
  fieldName,
  info,
  required
) {
  const fields = getFieldsByName(fieldName, info)
  const oldSelections = flatMap(fields, (field) => field.selectionSet.selections)

  const newInfo = buildInfo(rootFieldName, operation, info.schema, required)
  const { selectionSet } = newInfo.fieldNodes[0]

  selectionSet.selections = required
    ? selectionSet.selections.concat(oldSelections)
    : oldSelections

  // We need to copy fragments from original info object into the new info object
  const newInfoWithFragments = {
    ...newInfo,
    fragments: info.fragments,
  }

  return newInfoWithFragments
}

Simple Usage:

import pluckInfo from './utils/pluckInfo'

// Inside your resolver:
const userInfo = pluckInfo('login', 'mutation', 'user', info)
const user = await ctx.prisma.query.user({...}, userInfo)

I also renamed it to pluckInfo to stay a bit in style with addFragmentToInfo from graphql-binding. These two functions work very well hand in hand, but you might have to override the userInfo.returnType to avoid some errors.

More advanced example, appending the password field:

import pluckInfo from './utils/pluckInfo'
import { addFragmentToInfo } from 'graphql-binding'

// Inside your resolver:
let userInfo = pluckInfo('login', 'mutation', 'user', info)
userInfo.returnType = 'User' // Set correct return type
userInfo = addFragmentToInfo(userInfo, 'fragment UserPasword on User { password }')
const user = await ctx.prisma.query.user({
  where: { email: args.data.email }
}, userInfo)

// → do auth stuff here, using the new password field

delete user.password // remove the password before returning
return { user, token }

Edit: added more examples

ecker00 avatar Mar 16 '19 16:03 ecker00