prisma-binding
prisma-binding copied to clipboard
Manipulate resolver's info argument to optimize database access
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.
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.
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 } }',
});
Thanks a lot for bringing this up and creating a PR @gcangussu! We'll look into this shortly!
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
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 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 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.
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!
The pluck implementation is cool and should definitely be an official utility function.
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