Recently Apollo released official support for NextJS 13 App Router. Naturally I decided to give it a try with a new project. I was struggling to figure out how to properly handle new NextJS features and on-demand ISR. With RSC, now I can make calls to the database directly inside of component! Just one problem, you can't add revalidation tags to anything other than fetch. So if I make a mutation with a server action, I can't refetch data from another request. Basically, there is no easy “revalidate query” option like React Query and Apollo Client have. So if React Query doesn't yet support RSC, and Apollo Client does, it's time to bring in GraphQL.
I have said before that I do not generate GraphQL API's, I believe this is the wrong way to go. I tried initially to use Postgraphile and the results were from from what I wanted. This is no different than my experiences with every other GraphQL codegen tool. Which means, it's back to writing it by hand, the right way.
So I revisited an old blog post talking about how I wrote integrations between Prisma and GraphQL for performance. It took awhile to get up an running again. Fundamentally, it is impossible to automatically generate GraphQL schema from Prisma generated types as Prisma types are far more complex. Which means writing a lot of types by hand that look something like this.
1type Query {
2 learningLists(
3 where: LearningListWhereInput
4 orderBy: [LearningListOrderByWithRelationInput]
5 cursor: LearningListWhereUniqueInput
6 take: Int
7 skip: Int
8 distinct: [LearningListScalarFieldEnum]
9 ): [LearningList]
10}
1input PersonWhereInput {
2 AND: [PersonWhereInput]
3 OR: [PersonWhereInput]
4 NOT: [PersonWhereInput]
5 id: StringFilter
6 createdAt: DateTimeFilter
7 updatedAt: DateTimeFilter
8 username: StringFilter
9 profileImageUrl: StringFilter
10 clerkId: StringFilter
11 favoriteLists: LearningListListRelationFilter
12 learningList: LearningListListRelationFilter
13}
But the results are phenomenal. GraphQL becomes a fully customizable ORM that allows you to properly establish relationships between different data sources. GraphQL as it was intended. However, there's one major downside. And that's getting Prisma and GraphQL to play well together.
Prisma by itself is very performant. Recently 9x more performant. It takes a lot work out of manually writing SQL queries where other ORM's essentially just act as string builders. But with GraphQL, when you're making multiple nested calls Prisma loses the context for everything it's doing and can't build the most efficient queries. On top of the fact that Prisma does not batch many requests.
This is explained better in my original post on GraphQL. But the point here is that as I was working with it this time around, I found that the optimizations, especially around “one-to-many” queries, weren't being used. In many cases, the resolution was failing to find database relationships and falling back on N+1 queries.
So after a day of debugging here are my three functions to optimize Prisma with GraphQL.
The simple select to get rid of the dreaded `select *` and only select what was given to the GraphQL API.
1import { PrismaSelect } from '@paljs/plugins'
2import type { GraphQLResolveInfo } from 'graphql'
3
4export const select = <Type>(info: GraphQLResolveInfo): Type => {
5 const { select } = new PrismaSelect(info).value as { select: Type };
6
7 return select;
8};
Resolve arguments. This takes nested calls and turns them into new Prisma requests with relationships taken into account. The primary feature here is using relationship fields from a parent GraphQL query to make the next request, and yelling at you when you don't include it in that parent request select.
1import type { Prisma } from '@prisma/client';
2import type { GraphQLResolveInfo } from 'graphql';
3import { uniq } from 'lodash';
4
5import { select } from './select';
6
7export type RelationInfo = {
8 parentCallingFunction?: string;
9 parentColumnName: string;
10 parentTableName: string;
11 relationColumnName: string;
12 relationIndexName: string;
13};
14
15export type ResolvedArguments<SelectType> = {
16 rejectOnNotFound?: Prisma.RejectOnNotFound;
17 select?: SelectType | null;
18 where?: Record<string, unknown>;
19};
20
21type ResolveParentParameters<ArgumentsType> = {
22 arguments_: ArgumentsType;
23 info: GraphQLResolveInfo;
24 parent?: Record<string, unknown>;
25 relationInfo?: RelationInfo[];
26};
27
28export const resolveArguments = <
29 ArgumentsType extends ResolvedArguments<SelectType>,
30 SelectType,
31>(
32 parameters: ResolveParentParameters<ArgumentsType>,
33 ignoreSelect = false,
34): ArgumentsType => {
35 const parentQuery: Record<string, unknown> | undefined = parameters.parent;
36
37 if (parameters.relationInfo && parameters.parent) {
38 const indexArray = parameters.relationInfo.map(info => {
39 if (typeof info.parentCallingFunction === 'string') {
40 return `${info.parentTableName}${info.parentCallingFunction}`;
41 }
42
43 return info.parentTableName;
44 });
45
46 if (indexArray.length !== uniq(indexArray).length) {
47 const error = new Error(
48 'If there are more than one relationships for a table, specify a calling function.',
49 );
50 console.error(error.message);
51 throw error;
52 }
53 }
54
55 // Always add select argument, whether there is a relation or not.
56 let resolvedArguments = parameters.arguments_;
57 if (!ignoreSelect) {
58 resolvedArguments = {
59 ...resolvedArguments,
60 select: select<SelectType>(parameters.info),
61 };
62 }
63
64 const getResolvedArguments = (relation: RelationInfo): ArgumentsType => {
65 resolvedArguments = {
66 ...resolvedArguments,
67 ...parameters.arguments_,
68
69 where: {
70 [relation.relationColumnName]: parentQuery?.[relation.parentColumnName],
71 ...parameters.arguments_.where,
72 },
73 };
74
75 return resolvedArguments;
76 };
77
78 if (parameters.relationInfo && parameters.parent) {
79 for (const relation of parameters.relationInfo) {
80 if (parameters.info.parentType.name === relation.parentTableName) {
81 if (parentQuery?.[relation.parentColumnName] === undefined) {
82 throw new TypeError(
83 `Must call ${relation.parentColumnName} from ${relation.parentTableName}`,
84 );
85 }
86
87 if (
88 typeof relation.parentCallingFunction === 'string' &&
89 parameters.info.fieldName === relation.parentCallingFunction
90 ) {
91 return getResolvedArguments(relation);
92 }
93
94 if (typeof relation.parentCallingFunction !== 'string') {
95 return getResolvedArguments(relation);
96 }
97 }
98 }
99 }
100
101 return resolvedArguments;
102};
And the famous resolveFindMany which uses the Prisma fluent API and flips one-to-many requests around to allow for query batching.
1import type { GraphQLResolveInfo } from 'graphql';
2
3import type { ApolloContext } from '../route';
4import type { RelationInfo, ResolvedArguments } from './resolve-arguments';
5type ResolveQueryParameters = {
6 context: ApolloContext;
7 info: GraphQLResolveInfo;
8 modelName: string;
9 parent: Record<string, unknown> | undefined;
10 relationInfo?: RelationInfo[];
11 resolvedArguments: ResolvedArguments<unknown>;
12};
13
14export const resolveFindMany = async <ModelType>(
15 parameters: ResolveQueryParameters,
16): Promise<ModelType[]> => {
17 const lowercaseModelName = `${parameters.modelName
18 .charAt(0)
19 .toLowerCase()}${parameters.modelName.slice(1)}`;
20
21 if (parameters.relationInfo) {
22 for (const relation of parameters.relationInfo) {
23 if (relation.parentTableName === parameters.info.parentType.name) {
24 const lowercaseParentTableName = `${relation.parentTableName
25 .charAt(0)
26 .toLowerCase()}${relation.parentTableName.slice(1)}`;
27 const model = parameters.context.dataSources.prisma[
28 lowercaseParentTableName
29 ] as {
30 findUnique: ({ where: any }) => typeof parameters.modelName;
31 };
32
33 const relationValue = parameters.parent[relation.parentColumnName];
34
35 if (relationValue === undefined) {
36 throw new TypeError(
37 `Must call ${relation.parentColumnName} from ${relation.parentTableName}`,
38 );
39 }
40
41 // Try parentTable.findUnique().childTable()
42 // https://www.prisma.io/docs/guides/performance-and-optimization/query-optimization-performance#solving-the-n1-problem
43 try {
44 return model
45 .findUnique({
46 where: {
47 [relation.parentColumnName]: relationValue,
48 },
49 })
50 [relation.relationIndexName]({
51 select: parameters.resolvedArguments.select,
52 }) as ModelType[];
53 // If the parentTable -> childTable relationship has no index, fall back on original n + 1 issue.
54 // This creates a new SELECT for every parent result
55 } catch {
56 console.error(
57 `Make sure ${relation.parentTableName} has a foreign key constraint on ${parameters.modelName}.`,
58 );
59
60 return parameters.context.dataSources.prisma[
61 lowercaseModelName
62 ].findMany({
63 ...parameters.resolvedArguments,
64 }) as ModelType[];
65 }
66 }
67 }
68 }
69
70 // If relationship isn't defined, use n + 1 efficiency
71 return parameters.context.dataSources.prisma[lowercaseModelName].findMany({
72 ...parameters.resolvedArguments,
73 }) as ModelType[];
74};
Ultimately, I will be removing GraphQL from the project as well as avoiding NextJS server actions in favor of route handlers. The issue with Apollo Client here is that the server side client and client side client don't share a cache in the way that app router does with fetch. And Apollo's “refetchQueries” does nothing to invalidate the NextJS cache.
The only way to achieve this is to put all queries and mutations in route handlers and make fetch requests. This means revalidation tags can be added to GET requests. And the only place you can use revalidateTag() is in POST requests. Until third parties have an answer to this, I will have to stick with creating endpoints.