Skip to content

Commit 0ca4670

Browse files
authored
feat: optimistic update support for SWR (#860)
1 parent 0dbb741 commit 0ca4670

File tree

6 files changed

+380
-222
lines changed

6 files changed

+380
-222
lines changed

packages/plugins/swr/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
"nock": "^13.3.6",
5050
"react": "18.2.0",
5151
"rimraf": "^3.0.2",
52-
"swr": "^2.0.3",
52+
"swr": "^2.2.4",
5353
"ts-jest": "^29.0.5",
5454
"typescript": "^4.9.4"
5555
}

packages/plugins/swr/src/generator.ts

Lines changed: 92 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export async function generate(model: Model, options: PluginOptions, dmmf: DMMF.
3232
);
3333
}
3434

35+
const legacyMutations = options.legacyMutations !== false;
36+
3537
const models = getDataModels(model);
3638

3739
await generateModelMeta(project, models, {
@@ -49,73 +51,76 @@ export async function generate(model: Model, options: PluginOptions, dmmf: DMMF.
4951
warnings.push(`Unable to find mapping for model ${dataModel.name}`);
5052
return;
5153
}
52-
generateModelHooks(project, outDir, dataModel, mapping);
54+
generateModelHooks(project, outDir, dataModel, mapping, legacyMutations);
5355
});
5456

5557
await saveProject(project);
5658
return warnings;
5759
}
5860

59-
function generateModelHooks(project: Project, outDir: string, model: DataModel, mapping: DMMF.ModelMapping) {
61+
function generateModelHooks(
62+
project: Project,
63+
outDir: string,
64+
model: DataModel,
65+
mapping: DMMF.ModelMapping,
66+
legacyMutations: boolean
67+
) {
6068
const fileName = paramCase(model.name);
6169
const sf = project.createSourceFile(path.join(outDir, `${fileName}.ts`), undefined, { overwrite: true });
6270

6371
sf.addStatements('/* eslint-disable */');
6472

6573
const prismaImport = getPrismaClientImportSpec(model.$container, outDir);
6674
sf.addImportDeclaration({
67-
namedImports: ['Prisma', model.name],
75+
namedImports: ['Prisma'],
6876
isTypeOnly: true,
6977
moduleSpecifier: prismaImport,
7078
});
7179
sf.addStatements([
72-
`import { RequestHandlerContext, type GetNextArgs, type RequestOptions, type InfiniteRequestOptions, type PickEnumerable, type CheckSelect, useHooksContext } from '@zenstackhq/swr/runtime';`,
80+
`import { type GetNextArgs, type QueryOptions, type InfiniteQueryOptions, type MutationOptions, type PickEnumerable, useHooksContext } from '@zenstackhq/swr/runtime';`,
7381
`import metadata from './__model_meta';`,
7482
`import * as request from '@zenstackhq/swr/runtime';`,
7583
]);
7684

7785
const modelNameCap = upperCaseFirst(model.name);
7886
const prismaVersion = getPrismaVersion();
7987

80-
const useMutation = sf.addFunction({
81-
name: `useMutate${model.name}`,
82-
isExported: true,
83-
statements: [
84-
'const { endpoint, fetch, logging } = useHooksContext();',
85-
`const mutate = request.useMutate('${model.name}', metadata, logging);`,
86-
],
87-
});
88+
const useMutation = legacyMutations
89+
? sf.addFunction({
90+
name: `useMutate${model.name}`,
91+
isExported: true,
92+
statements: [
93+
'const { endpoint, fetch } = useHooksContext();',
94+
`const invalidate = request.useInvalidation('${model.name}', metadata);`,
95+
],
96+
})
97+
: undefined;
98+
8899
const mutationFuncs: string[] = [];
89100

90101
// create is somehow named "createOne" in the DMMF
91102
// eslint-disable-next-line @typescript-eslint/no-explicit-any
92103
if (mapping.create || (mapping as any).createOne) {
93104
const argsType = `Prisma.${model.name}CreateArgs`;
94-
const inputType = `Prisma.SelectSubset<T, ${argsType}>`;
95-
const returnType = `CheckSelect<T, ${model.name}, Prisma.${model.name}GetPayload<T>>`;
96-
mutationFuncs.push(
97-
generateMutation(useMutation, model, 'post', 'create', argsType, inputType, returnType, true)
98-
);
105+
mutationFuncs.push(generateMutation(sf, useMutation, model, 'POST', 'create', argsType, false));
99106
}
100107

101108
// createMany
102109
if (mapping.createMany) {
103110
const argsType = `Prisma.${model.name}CreateManyArgs`;
104-
const inputType = `Prisma.SelectSubset<T, ${argsType}>`;
105-
const returnType = `Prisma.BatchPayload`;
106-
mutationFuncs.push(
107-
generateMutation(useMutation, model, 'post', 'createMany', argsType, inputType, returnType, false)
108-
);
111+
mutationFuncs.push(generateMutation(sf, useMutation, model, 'POST', 'createMany', argsType, true));
109112
}
110113

111114
// findMany
112115
if (mapping.findMany) {
113116
const argsType = `Prisma.${model.name}FindManyArgs`;
114117
const inputType = `Prisma.SelectSubset<T, ${argsType}>`;
115-
const returnType = `Array<Prisma.${model.name}GetPayload<T>>`;
118+
const returnElement = `Prisma.${model.name}GetPayload<T>`;
119+
const returnType = `Array<${returnElement}>`;
120+
const optimisticReturn = `Array<${makeOptimistic(returnElement)}>`;
116121

117122
// regular findMany
118-
generateQueryHook(sf, model, 'findMany', argsType, inputType, returnType);
123+
generateQueryHook(sf, model, 'findMany', argsType, inputType, optimisticReturn, undefined, false);
119124

120125
// infinite findMany
121126
generateQueryHook(sf, model, 'findMany', argsType, inputType, returnType, undefined, true);
@@ -125,72 +130,52 @@ function generateModelHooks(project: Project, outDir: string, model: DataModel,
125130
if (mapping.findUnique) {
126131
const argsType = `Prisma.${model.name}FindUniqueArgs`;
127132
const inputType = `Prisma.SelectSubset<T, ${argsType}>`;
128-
const returnType = `Prisma.${model.name}GetPayload<T>`;
129-
generateQueryHook(sf, model, 'findUnique', argsType, inputType, returnType);
133+
const returnType = makeOptimistic(`Prisma.${model.name}GetPayload<T>`);
134+
generateQueryHook(sf, model, 'findUnique', argsType, inputType, returnType, undefined, false);
130135
}
131136

132137
// findFirst
133138
if (mapping.findFirst) {
134139
const argsType = `Prisma.${model.name}FindFirstArgs`;
135140
const inputType = `Prisma.SelectSubset<T, ${argsType}>`;
136-
const returnType = `Prisma.${model.name}GetPayload<T>`;
137-
generateQueryHook(sf, model, 'findFirst', argsType, inputType, returnType);
141+
const returnType = makeOptimistic(`Prisma.${model.name}GetPayload<T>`);
142+
generateQueryHook(sf, model, 'findFirst', argsType, inputType, returnType, undefined, false);
138143
}
139144

140145
// update
141146
// update is somehow named "updateOne" in the DMMF
142147
// eslint-disable-next-line @typescript-eslint/no-explicit-any
143148
if (mapping.update || (mapping as any).updateOne) {
144149
const argsType = `Prisma.${model.name}UpdateArgs`;
145-
const inputType = `Prisma.SelectSubset<T, ${argsType}>`;
146-
const returnType = `Prisma.${model.name}GetPayload<T>`;
147-
mutationFuncs.push(
148-
generateMutation(useMutation, model, 'put', 'update', argsType, inputType, returnType, true)
149-
);
150+
mutationFuncs.push(generateMutation(sf, useMutation, model, 'PUT', 'update', argsType, false));
150151
}
151152

152153
// updateMany
153154
if (mapping.updateMany) {
154155
const argsType = `Prisma.${model.name}UpdateManyArgs`;
155-
const inputType = `Prisma.SelectSubset<T, ${argsType}>`;
156-
const returnType = `Prisma.BatchPayload`;
157-
mutationFuncs.push(
158-
generateMutation(useMutation, model, 'put', 'updateMany', argsType, inputType, returnType, false)
159-
);
156+
mutationFuncs.push(generateMutation(sf, useMutation, model, 'PUT', 'updateMany', argsType, true));
160157
}
161158

162159
// upsert
163160
// upsert is somehow named "upsertOne" in the DMMF
164161
// eslint-disable-next-line @typescript-eslint/no-explicit-any
165162
if (mapping.upsert || (mapping as any).upsertOne) {
166163
const argsType = `Prisma.${model.name}UpsertArgs`;
167-
const inputType = `Prisma.SelectSubset<T, ${argsType}>`;
168-
const returnType = `Prisma.${model.name}GetPayload<T>`;
169-
mutationFuncs.push(
170-
generateMutation(useMutation, model, 'post', 'upsert', argsType, inputType, returnType, true)
171-
);
164+
mutationFuncs.push(generateMutation(sf, useMutation, model, 'POST', 'upsert', argsType, false));
172165
}
173166

174167
// del
175168
// delete is somehow named "deleteOne" in the DMMF
176169
// eslint-disable-next-line @typescript-eslint/no-explicit-any
177170
if (mapping.delete || (mapping as any).deleteOne) {
178171
const argsType = `Prisma.${model.name}DeleteArgs`;
179-
const inputType = `Prisma.SelectSubset<T, ${argsType}>`;
180-
const returnType = `Prisma.${model.name}GetPayload<T>`;
181-
mutationFuncs.push(
182-
generateMutation(useMutation, model, 'delete', 'delete', argsType, inputType, returnType, true)
183-
);
172+
mutationFuncs.push(generateMutation(sf, useMutation, model, 'DELETE', 'delete', argsType, false));
184173
}
185174

186175
// deleteMany
187176
if (mapping.deleteMany) {
188177
const argsType = `Prisma.${model.name}DeleteManyArgs`;
189-
const inputType = `Prisma.SelectSubset<T, ${argsType}>`;
190-
const returnType = `Prisma.BatchPayload`;
191-
mutationFuncs.push(
192-
generateMutation(useMutation, model, 'delete', 'deleteMany', argsType, inputType, returnType, false)
193-
);
178+
mutationFuncs.push(generateMutation(sf, useMutation, model, 'DELETE', 'deleteMany', argsType, true));
194179
}
195180

196181
// aggregate
@@ -283,7 +268,11 @@ function generateModelHooks(project: Project, outDir: string, model: DataModel,
283268
generateQueryHook(sf, model, 'count', argsType, inputType, returnType);
284269
}
285270

286-
useMutation.addStatements(`return { ${mutationFuncs.join(', ')} };`);
271+
useMutation?.addStatements(`return { ${mutationFuncs.join(', ')} };`);
272+
}
273+
274+
function makeOptimistic(returnType: string) {
275+
return `${returnType} & { $optimistic?: boolean }`;
287276
}
288277

289278
function generateIndex(project: Project, outDir: string, models: DataModel[]) {
@@ -321,7 +310,7 @@ function generateQueryHook(
321310
}
322311
parameters.push({
323312
name: 'options?',
324-
type: infinite ? `InfiniteRequestOptions<${returnType}>` : `RequestOptions<${returnType}>`,
313+
type: infinite ? `InfiniteQueryOptions<${returnType}>` : `QueryOptions<${returnType}>`,
325314
});
326315

327316
sf.addFunction({
@@ -332,40 +321,72 @@ function generateQueryHook(
332321
})
333322
.addBody()
334323
.addStatements([
335-
'const { endpoint, fetch } = useHooksContext();',
336324
!infinite
337-
? `return request.useGet<${returnType}>('${model.name}', '${operation}', endpoint, args, options, fetch);`
338-
: `return request.useInfiniteGet<${inputType} | undefined, ${returnType}>('${model.name}', '${operation}', endpoint, getNextArgs, options, fetch);`,
325+
? `return request.useModelQuery('${model.name}', '${operation}', args, options);`
326+
: `return request.useInfiniteModelQuery('${model.name}', '${operation}', getNextArgs, options);`,
339327
]);
340328
}
341329

342330
function generateMutation(
343-
func: FunctionDeclaration,
331+
sf: SourceFile,
332+
useMutateModelFunc: FunctionDeclaration | undefined,
344333
model: DataModel,
345-
method: 'post' | 'put' | 'patch' | 'delete',
334+
method: 'POST' | 'PUT' | 'PATCH' | 'DELETE',
346335
operation: string,
347336
argsType: string,
348-
inputType: string,
349-
returnType: string,
350-
checkReadBack: boolean
337+
batchResult: boolean
351338
) {
339+
// non-batch mutations are subject to read-back check
340+
const checkReadBack = !batchResult;
341+
const genericReturnType = batchResult ? 'Prisma.BatchPayload' : `Prisma.${model.name}GetPayload<T> | undefined`;
342+
const returnType = batchResult ? 'Prisma.BatchPayload' : `Prisma.${model.name}GetPayload<${argsType}> | undefined`;
343+
const genericInputType = `Prisma.SelectSubset<T, ${argsType}>`;
344+
352345
const modelRouteName = lowerCaseFirst(model.name);
353346
const funcName = `${operation}${model.name}`;
354-
const fetcherFunc = method === 'delete' ? 'del' : method;
355-
func.addFunction({
356-
name: funcName,
357-
isAsync: true,
358-
typeParameters: [`T extends ${argsType}`],
347+
348+
if (useMutateModelFunc) {
349+
// generate async mutation function (legacy)
350+
const mutationFunc = useMutateModelFunc.addFunction({
351+
name: funcName,
352+
isAsync: true,
353+
typeParameters: [`T extends ${argsType}`],
354+
parameters: [
355+
{
356+
name: 'args',
357+
type: genericInputType,
358+
},
359+
],
360+
});
361+
mutationFunc.addJsDoc(`@deprecated Use \`use${upperCaseFirst(operation)}${model.name}\` hook instead.`);
362+
mutationFunc
363+
.addBody()
364+
.addStatements([
365+
`return await request.mutationRequest<${returnType}, ${checkReadBack}>('${method}', \`\${endpoint}/${modelRouteName}/${operation}\`, args, invalidate, fetch, ${checkReadBack});`,
366+
]);
367+
}
368+
369+
// generate mutation hook
370+
sf.addFunction({
371+
name: `use${upperCaseFirst(operation)}${model.name}`,
372+
isExported: true,
359373
parameters: [
360374
{
361-
name: 'args',
362-
type: inputType,
375+
name: 'options?',
376+
type: `MutationOptions<${returnType}, unknown, ${argsType}>`,
363377
},
364378
],
365379
})
366380
.addBody()
367381
.addStatements([
368-
`return await request.${fetcherFunc}<${returnType}, ${checkReadBack}>(\`\${endpoint}/${modelRouteName}/${operation}\`, args, mutate, fetch, ${checkReadBack});`,
382+
`const mutation = request.useModelMutation('${model.name}', '${method}', '${operation}', metadata, options, ${checkReadBack});`,
383+
`return {
384+
...mutation,
385+
trigger<T extends ${argsType}>(args: ${genericInputType}) {
386+
return mutation.trigger(args, options as any) as Promise<${genericReturnType}>;
387+
}
388+
};`,
369389
]);
390+
370391
return funcName;
371392
}

0 commit comments

Comments
 (0)