Making Prisma/GraphQL Better

Ethan Glover

GraphQL is faster than REST. The specification itself doesn't make anything inherently faster. But by allowing the client to make highly specific requests, it naturally uses less resources.

That being said, because GraphQL is just a specification and doesn't handle logic for you, just because you're using GraphQL doesn't mean you're taking full advantage.

For example, when you use Prisma to write a resolver to fetch an item, GraphQL itself will only retrieve the data the caller requested. However, that does not mean Prisma won't retrieve that excess data from the database server side.

Select All

1query Query {
2  user(id: 123) {
3    username
4  }

This query will only return the username field from a user resolver. (Let's assume we're talking about a user database here.) However, that does not mean the Prisma function used to retrieve this data doesn't do:

1select * from user where id = 123

Luckily, PalJs provides an easyPrismaSelect function that looks at the info object from the GraphQL query and makes sure to only select the fields requested. You can abstract this into a very small and simple function to make it easy to use for all resolvers (info comes from every incoming Apollo query):

1import { PrismaSelect } from '@paljs/plugins';
3export const select = <Type>(info: GraphQLResolveInfo): Type => {
4  const { select } = new PrismaSelect(info).value as { select: Type };  
6  return select;
9const user = await prisma.user.findUnique({
10  select: select<Prisma.UserSelect>(info),
11  where: { id: 123 },

This will transform that select all SQL query into the following:

1select username from user where id = 123


One of the challenges of writing resolvers can be creating relationships between tables. For example, I want to make the below query. The user table is related to the blog table with a relationship of userId => authorId.

1query Query {
2  user(id: 123) {
3    blogs {
4      title
5    }
6  }

There are a couple of things I want Prisma to do here.

  • Recognize that the initial query to the users table needs to get userId if the child query is querying the blog table.
  • Recognize that the userId field on user relates to the authorId field on blog.

I don't want the caller to have to select the userId field and I don't want to spend too much time writing if statements for every resolver relationship. And unfortunately, the GraphQL info object and Prisma's generated types don't help us much here.

So what I've come up with is a resolveArguments function to do all of this work for me. Here's how it works:

The key to the functionality is in the array of “RelationInfo” objects. Each item in the array represents a relationship between the resolver on the current table and the parent table in the nested GraphQL query. We use this function in the child resolver. Sticking with our example above, we'd put this in the blogs resolver, not the user resolver.

1export interface RelationInfo {
2  parentColumnName: string;
3  parentTableName: string;
4  relationColumnName: string;
1const resolvedArguments = resolveArguments({
2  arguments_,
3  info,
4  parent,
5  relationInfo: [
6    {
7      parentColumnName: Prisma.User.userId,
8      parentTableName: Prisma.ModelName.User,
9      relationColumnName: Prisma.Blog.authorId,
10    },
11  ]

Let's walk through what this function does. From the start, while we're resolving arguments we can go ahead and apply our select abstraction from above.

1let resolvedArguments = parameters.arguments_;
2if (!ignoreSelect) {
3 = select<SelectType>(;

From here, we can start filling out the request. We do that by looping over the relationInfo array and finding which one of it's parent table names relates to the info objects parent table name.

1for (const relation of parameters.relationInfo) {
2  if ( === relation.parentTableName) {
3  }

While I mentioned that I don't want the caller to have to select userId, unfortunately you have to remember we're starting from the child table. This logic is happening in the blogs query and we don't have control over what happens in the user query at this point. So we need that userId.

It would at least be a nice courtesy to let users know which field needs to be queried:

1if (typeof thisParent?.[relation.parentColumnName] === 'undefined') {
2  throw new TypeError(
3    `Must call ${relation.parentColumnName} from ${relation.parentTableName}`
4  );

Otherwise, we can move on and get our resolved arguments. Remember the point of this was to make sure we're using the appropriate relationship fields. So essentially all we need to do is make sure Prisma's where statement contains authorId: userId.

1return {
2  ...parameters.arguments_,
3  where: {
4    [relation.relationColumnName]: thisParent?.[relation.parentColumnName],
5    ...parameters.arguments_.where,
6  },

Simple as that, we use the spread operator to make sure we're not stripping out any fields from the original request and simply add a where for the relationship. Altogether it would look something like this:

1interface RelationInfo {
2  parentColumnName: string;
3  parentTableName: string;
4  relationColumnName: string;
7interface ResolveParentParameters<ArgumentsType> {
8  arguments_: ArgumentsType;
9  info: GraphQLResolveInfo;
10  parent?: Record<string, unknown>;
11  relationInfo?: RelationInfo[];
14const resolvedArguments = resolveArguments({
15  arguments_,
16  info,
17  parent,
18  relationInfo: [
19    {
20      parentColumnName: Prisma.User.userId,
21      parentTableName: Prisma.ModelName.User,
22      relationColumnName: Prisma.Blog.authorId,
23    },
24  ],
27const resolveArguments = <
28  ArgumentsType extends ResolvedArguments<SelectType>,
29  SelectType
31  parameters: ResolveParentParameters<ArgumentsType>,
32  ignoreSelect = false
33): ArgumentsType => {
34  let resolvedArguments = parameters.arguments_;
35  if (!ignoreSelect) {
36 = select<SelectType>(;
37  }
39  for (const relation of parameters.relationInfo) {
40    if ( === relation.parentTableName) {
41      if (typeof thisParent?.[relation.parentColumnName] === 'undefined') {
42        throw new TypeError(
43          `Must call ${relation.parentColumnName} from ${relation.parentTableName}`
44        );
45      }
47      return {
48        ...resolvedArguments,
49        ...parameters.arguments_,
51        where: {
52          [relation.relationColumnName]: thisParent?.[relation.parentColumnName],
53          ...parameters.arguments_.where,
54        },
55      }
56    }
57  }
1const blogs = async (
2  parent: Record<string, unknown> | undefined,
3  arguments_: Prisma.BlogFindManyArgs,
4  context: Context
5  info: GraphQLResolveInfo
6): Promise<Blog[]> => {
7  const resolvedArguments = resolveArguments({
8    arguments_,
9    info,
10    parent,
11    relationInfo: [
12      {
13        parentColumnName: Prisma.UserScalarFieldEnum.userId,
14        parentTableName: Prisma.ModelName.User,
15        relationColumnName: Prisma.BlogScalarFieldEnum.authorId,
16      },
17    ]
18  });
20  return prisma.blogs.findMany({ ...resolvedArguments });

This allows you to create boilerplate resolvers that only require a relationship array and the default parameters Apollo provides from incoming queries. It also helps Prisma generate smarter SQL queries. The SQL query here would be something like this:

1select userId from User where userId = ‘userId’
2select title from blog where authorId = ‘userId’

In case you're wondering why Prisma generates two SQL statements instead of a Join. It's because it's more efficient. You'll notice that both are using the same userId from the original arguments. These can fire concurrently. This is a simple query, you could have queried the blog table directly with the userId. But as these scale up and become more complex, Prisma will generate different SQL.

The point of these abstractions is to take advantage of Prisma as a backend. Prisma is really freaking smart. Ideally you want to give it as much information as possible (such as relationship info) and let it do it's thing. In the case of GraphQL where a single post request can be querying multiple things, Prisma will read everything and generate the smartest query it can based on that context. Meaning, it doesn't just fire off SQL queries one at a time as it hits your resolvers. It intelligently looks at all incoming requests before hitting the database and generates what it believes to be the best queries (it's usually right).

Solving The Many-To-Many N+1 Problem

ORM's get criticized for the N+1 problem. As Prisma's optimization guide says:

The n+1 problem occurs when you loop through the results of a query and perform one additional query per result, resulting in n number of queries plus the original (n+1). This is a common problem with ORMs, particularly in combination with GraphQL, because it is not always immediately obvious that your code is generating inefficient queries.

- Prisma, Query Optimization

However, that doesn't mean this issue can't be solved on a programmatic level such that we can automatically generate efficient queries. As noted before, Prisma batches incoming queries to create more intelligent SQL queries. However, this does not apply to findMany queries.

Because this site doesn't use a database. (On Hosting Static Pages) I'm going to pull an example from a real-world production app where I implemented this solution. I'm running this query where sites refers to a location and caseData refers to a support ticket. I used the following query:

1query Query {
2  sites
3    SiteID
4    caseDatas {
5      CaseID
6    }
7  }

Without optimizing for N+1 you get one query selecting all sites and a new query for each of those sites selecting all caseDatas. The generated SQL from prisma looks like this:

Unresolved site, case data.

This query took about 6 seconds to complete. Here's what it looks like after using my resolveFindMany() function.

Resolved site, case data.

Now, instead of hundreds of individual select statements we get three queries. The first is the same, we're basically just grabbing all sites. Then, it grabs all siteId's (which is the column we're using as a relationship to caseData). Last it grabs the caseData. Note that instead of Join, Prisma is using SQL's IN keyword here.

You could bring up a lot of arguments about this implementation. Complain about the first two queries being essentially the same. Argue that Join would be faster. (It is not.) At the end of the day, this implementation is based on Prisma's research and use of relationships to create efficient queries.

This second query took about 4 seconds. A 2 second savings. Keep in mind that I am not running this multiple times or doing any real benchmarking. But a 2 second difference is significant. Normally, you would want to avoid this kind of query altogether. Instead opt for pagination. In the real world, this abstraction cuts down a significant amount of time on any findMany query without much effort from the developer. Simply use the resolveFindMany() function.

So what does this function look like? It is honestly very nasty and not totally TypeScript friendly. I'll give a basic list of what it does and leave the entire function I'm using in production below. For now we'll keep using the site caseData query as an example.

  • From the caseData resolver use the same relationship array resolveArguments from above does to get the parent model.
  • Get the relationValue (siteId) from the parent object. (Site.siteId)
  • Run a findUnique on the parent model (Site) where the parentColumnName is equal to relationValue.
  • Chain the current table (caseData) to that findUnique query to run them together.

What you end up with is something like this:

1Prisma.Site.findUnique({where: {siteId}}).CaseData()

This is created for every site found on the parent query. Remember, Prisma batches findUnique queries. So by generating a findUnique query for every N results from sites, Prisma will batch them together using SQL's IN keyword as seen above.

So the issue is just generating this based on the GraphQL info object. Meaning, we want our resolver to return something like this:

1return resolveFindMany({
2  context,
3  info,
4  modelName: AmsData.ModelName.CaseData,
5  parent,
6  relationInfo,
7  resolvedArguments,

We're again passing along the default argument on every Apollo query but also including a relationInfo array and some resolvedArguments (using the above mentioned code).

This is the messy TypeScript confused function I currently use (totally functional and performant):

2 * In relationships provide index name if it exists.
3 * Reference node_modules\.prisma\client\index.d.ts
4 * ex. DomainUsers -> Bills created by DomainUser
5 * ex. DomainUser.Bill_Bill_CreatedByToDomainUser, relationInfo.relationIndexName = 'Bill_Bill_CreatedByToDomainUser'
6 */
7export const resolveFindMany = async <ModelType>(
8  parameters: ResolveQueryParameters
9  // eslint-disable-next-line sonarjs/cognitive-complexity
10): Promise<ModelType[]> => {
11  if (parameters.relationInfo) {
12    for (const relation of parameters.relationInfo) {
13      if (relation.parentTableName === {
14        // @ts-expect-error We're looking for table by parentTableName
15        const model = parameters.context.amsData[
16          relation.parentTableName.charAt(0).toLowerCase() +
17            relation.parentTableName.slice(1)
18        ] as {
19          // @ts-expect-error This is just an assumption that the table has a findUnique method
20          findUnique: ({ where: any }) => typeof parameters.modelName;
21        };
23        // @ts-expect-error We're looking for a column by the columnName
24        const relationValue = parameters.parent[relation.parentColumnName];
26        if (typeof relationValue === 'undefined') {
27          throw new TypeError(
28            `Must call ${relation.parentColumnName} from ${relation.parentTableName}`
29          );
30        }
32        // Try parentTable.findUnique().childTable()
33        //
34        try {
35          // @ts-expect-error Resolved arguments returns a where if a relationship exists
36          delete parameters.resolvedArguments.where[
37            // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
38            relation.relationColumnName
39          ];
41          return (
42            // eslint-disable-next-line @typescript-eslint/no-unsafe-call
43            model
44              .findUnique({
45                where: {
46                  [relation.parentColumnName]: relationValue,
47                },
48              })
49              [
50                // @ts-expect-error Allow to search by column using either string
51                typeof relation.relationIndexName === 'string'
52                  ? relation.relationIndexName
53                  : parameters.modelName
54              ]({
55                ...parameters.resolvedArguments,
56              }) as ModelType[]
57          );
58          // If the parentTable -> childTable relationship has no index, fall back on original n + 1 issue.
59          // This creates a new SELECT for every parent result
60        } catch {
61          console.error(
62            `Make sure ${relation.parentTableName} has a foreign key constraint on ${parameters.modelName}.`
63          );
65          // This value was deleted above, readd
66          // @ts-expect-error Resolved arguments returns a where in case of relationships.
67          parameters.resolvedArguments.where[relation.relationColumnName] =
68            relationValue;
70          // @ts-expect-error Get table by table name
71          // eslint-disable-next-line @typescript-eslint/no-unsafe-call
72          return parameters.context.amsData[
73            parameters.modelName.charAt(0).toLowerCase() +
74              parameters.modelName.slice(1)
75          ].findMany({
76            ...parameters.resolvedArguments,
77          }) as ModelType[];
78        }
79      }
80    }
81  }
83  // If relationship isn't defined, use n + 1 efficiency
84  // @ts-expect-error Get table by table name
85  // eslint-disable-next-line @typescript-eslint/no-unsafe-call
86  return parameters.context.amsData[
87    parameters.modelName.charAt(0).toLowerCase() + parameters.modelName.slice(1)
88  ].findMany({
89    ...parameters.resolvedArguments,
90  }) as ModelType[];

Last Words

GraphQL is a huge booster to any API. It makes for a good Gateway when using a lot of third-party API's. It's more performant, and easier to work with and maintain than a REST API.

However, that doesn't mean everything is done for you out of the box. GraphQL is a specification, nothing more. You can use services like Hasura to generate GraphQL API's but if you're looking to build your own, it does involve writing code.

The initial perception a lot of people have of it is that it somehow just magically does everything for you. Not recognizing that all it can do is map requests to resolvers and return data based on that.

I've had to listen to professional developers get confused over how authentication works with GraphQL... as if REST we're handling it for them previously. The internet still works the same when you're using GraphQL. Headers and post data still exist.

The point of this post was to share how I've been able to generate efficient queries with Prisma using GraphQL queries. As this is one of the things that make people skeptical of GraphQL I will say I found making these sort of programmatic abstractions based on incoming data way easier when I have access to GraphQL's info object and Prisma's batching capabilities.