Skip to content

Commit 42d654f

Browse files
authored
feat: improved automatic query invalidation for tanstack-query (#790)
1 parent 6dabb02 commit 42d654f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+3657
-1363
lines changed

packages/plugins/openapi/src/rest-generator.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import * as path from 'path';
1919
import pluralize from 'pluralize';
2020
import invariant from 'tiny-invariant';
2121
import YAML from 'yaml';
22+
import { name } from '.';
2223
import { OpenAPIGeneratorBase } from './generator-base';
2324
import { getModelResourceMeta } from './meta';
2425

@@ -31,7 +32,7 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase {
3132
private warnings: string[] = [];
3233

3334
generate() {
34-
let output = requireOption<string>(this.options, 'output');
35+
let output = requireOption<string>(this.options, 'output', name);
3536
output = resolvePath(output, this.options);
3637

3738
const components = this.generateComponents();

packages/plugins/openapi/src/rpc-generator.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import * as path from 'path';
1818
import invariant from 'tiny-invariant';
1919
import { upperCaseFirst } from 'upper-case-first';
2020
import YAML from 'yaml';
21+
import { name } from '.';
2122
import { OpenAPIGeneratorBase } from './generator-base';
2223
import { getModelResourceMeta } from './meta';
2324

@@ -32,7 +33,7 @@ export class RPCOpenAPIGenerator extends OpenAPIGeneratorBase {
3233
private warnings: string[] = [];
3334

3435
generate() {
35-
let output = requireOption<string>(this.options, 'output');
36+
let output = requireOption<string>(this.options, 'output', name);
3637
output = resolvePath(output, this.options);
3738

3839
// input types

packages/plugins/swr/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@zenstackhq/runtime": "workspace:*",
3030
"@zenstackhq/sdk": "workspace:*",
3131
"change-case": "^4.1.2",
32+
"cross-fetch": "^4.0.0",
3233
"decimal.js": "^10.4.2",
3334
"lower-case-first": "^2.0.2",
3435
"semver": "^7.3.8",
@@ -37,6 +38,7 @@
3738
},
3839
"devDependencies": {
3940
"@tanstack/react-query": "^4.28.0",
41+
"@testing-library/react": "^14.0.0",
4042
"@types/jest": "^29.5.0",
4143
"@types/node": "^18.0.0",
4244
"@types/react": "18.2.0",
@@ -45,6 +47,7 @@
4547
"@zenstackhq/testtools": "workspace:*",
4648
"copyfiles": "^2.4.1",
4749
"jest": "^29.5.0",
50+
"nock": "^13.3.6",
4851
"react": "18.2.0",
4952
"rimraf": "^3.0.2",
5053
"swr": "^2.0.3",

packages/plugins/swr/src/generator.ts

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { DMMF } from '@prisma/generator-helper';
22
import {
33
PluginOptions,
44
createProject,
5+
generateModelMeta,
56
getDataModels,
67
getPrismaClientImportSpec,
78
getPrismaVersion,
@@ -16,9 +17,10 @@ import path from 'path';
1617
import semver from 'semver';
1718
import { FunctionDeclaration, OptionalKind, ParameterDeclarationStructure, Project, SourceFile } from 'ts-morph';
1819
import { upperCaseFirst } from 'upper-case-first';
20+
import { name } from '.';
1921

2022
export async function generate(model: Model, options: PluginOptions, dmmf: DMMF.Document) {
21-
let outDir = requireOption<string>(options, 'output');
23+
let outDir = requireOption<string>(options, 'output', name);
2224
outDir = resolvePath(outDir, options);
2325

2426
const project = createProject();
@@ -32,6 +34,8 @@ export async function generate(model: Model, options: PluginOptions, dmmf: DMMF.
3234

3335
const models = getDataModels(model);
3436

37+
await generateModelMeta(project, models, path.join(outDir, '__model_meta.ts'), false, true);
38+
3539
generateIndex(project, outDir, models);
3640

3741
models.forEach((dataModel) => {
@@ -60,24 +64,20 @@ function generateModelHooks(project: Project, outDir: string, model: DataModel,
6064
moduleSpecifier: prismaImport,
6165
});
6266
sf.addStatements([
63-
`import { useContext } from 'react';`,
64-
`import { RequestHandlerContext, type GetNextArgs, type RequestOptions, type InfiniteRequestOptions, type PickEnumerable, type CheckSelect } from '@zenstackhq/swr/runtime';`,
67+
`import { RequestHandlerContext, type GetNextArgs, type RequestOptions, type InfiniteRequestOptions, type PickEnumerable, type CheckSelect, useHooksContext } from '@zenstackhq/swr/runtime';`,
68+
`import metadata from './__model_meta';`,
6569
`import * as request from '@zenstackhq/swr/runtime';`,
6670
]);
6771

6872
const modelNameCap = upperCaseFirst(model.name);
6973
const prismaVersion = getPrismaVersion();
7074

71-
const prefixesToMutate = ['find', 'aggregate', 'count', 'groupBy'];
7275
const useMutation = sf.addFunction({
7376
name: `useMutate${model.name}`,
7477
isExported: true,
7578
statements: [
76-
'const { endpoint, fetch } = useContext(RequestHandlerContext);',
77-
`const prefixesToMutate = [${prefixesToMutate
78-
.map((prefix) => '`${endpoint}/' + lowerCaseFirst(model.name) + '/' + prefix + '`')
79-
.join(', ')}];`,
80-
'const mutate = request.getMutate(prefixesToMutate);',
79+
'const { endpoint, fetch, logging } = useHooksContext();',
80+
`const mutate = request.useMutate('${model.name}', metadata, logging);`,
8181
],
8282
});
8383
const mutationFuncs: string[] = [];
@@ -297,8 +297,6 @@ function generateQueryHook(
297297
typeParameters?: string[],
298298
infinite = false
299299
) {
300-
const modelRouteName = lowerCaseFirst(model.name);
301-
302300
const typeParams = typeParameters ? [...typeParameters] : [`T extends ${argsType}`];
303301
if (infinite) {
304302
typeParams.push(`R extends ${returnType}`);
@@ -329,10 +327,10 @@ function generateQueryHook(
329327
})
330328
.addBody()
331329
.addStatements([
332-
'const { endpoint, fetch } = useContext(RequestHandlerContext);',
330+
'const { endpoint, fetch } = useHooksContext();',
333331
!infinite
334-
? `return request.get<${returnType}>(\`\${endpoint}/${modelRouteName}/${operation}\`, args, options, fetch);`
335-
: `return request.infiniteGet<${inputType} | undefined, ${returnType}>(\`\${endpoint}/${modelRouteName}/${operation}\`, getNextArgs, options, fetch);`,
332+
? `return request.useGet<${returnType}>('${model.name}', '${operation}', endpoint, args, options, fetch);`
333+
: `return request.useInfiniteGet<${inputType} | undefined, ${returnType}>('${model.name}', '${operation}', endpoint, getNextArgs, options, fetch);`,
336334
]);
337335
}
338336

packages/plugins/swr/src/runtime/index.ts

Lines changed: 108 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22
import { deserialize, serialize } from '@zenstackhq/runtime/browser';
3-
import { createContext } from 'react';
3+
import { getMutatedModels, getReadModels, type ModelMeta, type PrismaWriteActionType } from '@zenstackhq/runtime/cross';
4+
import * as crossFetch from 'cross-fetch';
5+
import { lowerCaseFirst } from 'lower-case-first';
6+
import { createContext, useContext } from 'react';
47
import type { Fetcher, MutatorCallback, MutatorOptions, SWRConfiguration, SWRResponse } from 'swr';
58
import useSWR, { useSWRConfig } from 'swr';
69
import useSWRInfinite, { SWRInfiniteConfiguration, SWRInfiniteFetcher, SWRInfiniteResponse } from 'swr/infinite';
@@ -18,19 +21,26 @@ export type RequestHandlerContext = {
1821
/**
1922
* The endpoint to use for the queries.
2023
*/
21-
endpoint: string;
24+
endpoint?: string;
2225

2326
/**
2427
* A custom fetch function for sending the HTTP requests.
2528
*/
2629
fetch?: FetchFn;
30+
31+
/**
32+
* If logging is enabled.
33+
*/
34+
logging?: boolean;
2735
};
2836

37+
const DEFAULT_QUERY_ENDPOINT = '/api/model';
38+
2939
/**
3040
* Context for configuring react hooks.
3141
*/
3242
export const RequestHandlerContext = createContext<RequestHandlerContext>({
33-
endpoint: '/api/model',
43+
endpoint: DEFAULT_QUERY_ENDPOINT,
3444
fetch: undefined,
3545
});
3646

@@ -39,6 +49,14 @@ export const RequestHandlerContext = createContext<RequestHandlerContext>({
3949
*/
4050
export const Provider = RequestHandlerContext.Provider;
4151

52+
/**
53+
* Hooks context.
54+
*/
55+
export function useHooksContext() {
56+
const { endpoint, ...rest } = useContext(RequestHandlerContext);
57+
return { endpoint: endpoint ?? DEFAULT_QUERY_ENDPOINT, ...rest };
58+
}
59+
4260
/**
4361
* Client request options for regular query.
4462
*/
@@ -69,6 +87,29 @@ export type InfiniteRequestOptions<Result, Error = any> = {
6987
initialData?: Result[];
7088
} & SWRInfiniteConfiguration<Result, Error, SWRInfiniteFetcher<Result>>;
7189

90+
export const QUERY_KEY_PREFIX = 'zenstack';
91+
92+
type QueryKey = { prefix: typeof QUERY_KEY_PREFIX; model: string; operation: string; args: unknown };
93+
94+
export function getQueryKey(model: string, operation: string, args?: unknown) {
95+
return JSON.stringify({ prefix: QUERY_KEY_PREFIX, model, operation, args });
96+
}
97+
98+
export function parseQueryKey(key: unknown) {
99+
if (typeof key !== 'string') {
100+
return undefined;
101+
}
102+
try {
103+
const parsed = JSON.parse(key);
104+
if (!parsed || parsed.prefix !== QUERY_KEY_PREFIX) {
105+
return undefined;
106+
}
107+
return parsed as QueryKey;
108+
} catch {
109+
return undefined;
110+
}
111+
}
112+
72113
/**
73114
* Makes a GET request with SWR.
74115
*
@@ -79,14 +120,17 @@ export type InfiniteRequestOptions<Result, Error = any> = {
79120
* @returns SWR response
80121
*/
81122
// eslint-disable-next-line @typescript-eslint/no-explicit-any
82-
export function get<Result, Error = any>(
83-
url: string | null,
123+
export function useGet<Result, Error = any>(
124+
model: string,
125+
operation: string,
126+
endpoint: string,
84127
args?: unknown,
85128
options?: RequestOptions<Result, Error>,
86129
fetch?: FetchFn
87130
): SWRResponse<Result, Error> {
88-
const reqUrl = options?.disabled ? null : url ? makeUrl(url, args) : null;
89-
return useSWR<Result, Error>(reqUrl, (url) => fetcher<Result, false>(url, undefined, fetch, false), {
131+
const key = options?.disabled ? null : getQueryKey(model, operation, args);
132+
const url = makeUrl(`${endpoint}/${lowerCaseFirst(model)}/${operation}`, args);
133+
return useSWR<Result, Error>(key, () => fetcher<Result, false>(url, undefined, fetch, false), {
90134
...options,
91135
fallbackData: options?.initialData ?? options?.fallbackData,
92136
});
@@ -107,26 +151,40 @@ export type GetNextArgs<Args, Result> = (pageIndex: number, previousPageData: Re
107151
* @returns SWR infinite query response
108152
*/
109153
// eslint-disable-next-line @typescript-eslint/no-explicit-any
110-
export function infiniteGet<Args, Result, Error = any>(
111-
url: string | null,
154+
export function useInfiniteGet<Args, Result, Error = any>(
155+
model: string,
156+
operation: string,
157+
endpoint: string,
112158
getNextArgs: GetNextArgs<Args, any>,
113159
options?: InfiniteRequestOptions<Result, Error>,
114160
fetch?: FetchFn
115161
): SWRInfiniteResponse<Result, Error> {
116162
const getKey = (pageIndex: number, previousPageData: Result | null) => {
117-
if (options?.disabled || !url) {
163+
if (options?.disabled) {
118164
return null;
119165
}
120166
const nextArgs = getNextArgs(pageIndex, previousPageData);
121167
return nextArgs !== null // null means reached the end
122-
? makeUrl(url, nextArgs)
168+
? getQueryKey(model, operation, nextArgs)
123169
: null;
124170
};
125171

126-
return useSWRInfinite<Result, Error>(getKey, (url) => fetcher<Result, false>(url, undefined, fetch, false), {
127-
...options,
128-
fallbackData: options?.initialData ?? options?.fallbackData,
129-
});
172+
return useSWRInfinite<Result, Error>(
173+
getKey,
174+
(key) => {
175+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
176+
const parsedKey = parseQueryKey(key)!;
177+
const url = makeUrl(
178+
`${endpoint}/${lowerCaseFirst(parsedKey.model)}/${parsedKey.operation}`,
179+
parsedKey.args
180+
);
181+
return fetcher<Result, false>(url, undefined, fetch, false);
182+
},
183+
{
184+
...options,
185+
fallbackData: options?.initialData ?? options?.fallbackData,
186+
}
187+
);
130188
}
131189

132190
/**
@@ -155,7 +213,7 @@ export async function post<Result, C extends boolean = boolean>(
155213
fetch,
156214
checkReadBack
157215
);
158-
mutate();
216+
mutate(getOperationFromUrl(url), data);
159217
return r;
160218
}
161219

@@ -185,7 +243,7 @@ export async function put<Result, C extends boolean = boolean>(
185243
fetch,
186244
checkReadBack
187245
);
188-
mutate();
246+
mutate(getOperationFromUrl(url), data);
189247
return r;
190248
}
191249

@@ -212,29 +270,42 @@ export async function del<Result, C extends boolean = boolean>(
212270
fetch,
213271
checkReadBack
214272
);
215-
const path = url.split('/');
216-
path.pop();
217-
mutate();
273+
mutate(getOperationFromUrl(url), args);
218274
return r;
219275
}
220276

221277
type Mutator = (
278+
operation: string,
222279
data?: unknown | Promise<unknown> | MutatorCallback,
223280
opts?: boolean | MutatorOptions
224281
) => Promise<unknown[]>;
225282

226-
export function getMutate(prefixes: string[]): Mutator {
283+
export function useMutate(model: string, modelMeta: ModelMeta, logging?: boolean): Mutator {
227284
// https://swr.vercel.app/docs/advanced/cache#mutate-multiple-keys-from-regex
228285
const { cache, mutate } = useSWRConfig();
229-
return (data?: unknown | Promise<unknown> | MutatorCallback, opts?: boolean | MutatorOptions) => {
286+
return async (operation: string, args: unknown, opts?: boolean | MutatorOptions) => {
230287
if (!(cache instanceof Map)) {
231288
throw new Error('mutate requires the cache provider to be a Map instance');
232289
}
233290

234-
const keys = Array.from(cache.keys()).filter(
235-
(k) => typeof k === 'string' && prefixes.some((prefix) => k.startsWith(prefix))
236-
) as string[];
237-
const mutations = keys.map((key) => mutate(key, data, opts));
291+
const mutatedModels = await getMutatedModels(model, operation as PrismaWriteActionType, args, modelMeta);
292+
293+
const keys = Array.from(cache.keys()).filter((key) => {
294+
const parsedKey = parseQueryKey(key);
295+
if (!parsedKey) {
296+
return false;
297+
}
298+
const modelsRead = getReadModels(parsedKey.model, modelMeta, parsedKey.args);
299+
return modelsRead.some((m) => mutatedModels.includes(m));
300+
});
301+
302+
if (logging) {
303+
keys.forEach((key) => {
304+
console.log(`Invalidating query ${key} due to mutation "${model}.${operation}"`);
305+
});
306+
}
307+
308+
const mutations = keys.map((key) => mutate(key, undefined, opts));
238309
return Promise.all(mutations);
239310
};
240311
}
@@ -245,7 +316,7 @@ export async function fetcher<R, C extends boolean>(
245316
fetch?: FetchFn,
246317
checkReadBack?: C
247318
): Promise<C extends true ? R | undefined : R> {
248-
const _fetch = fetch ?? window.fetch;
319+
const _fetch = fetch ?? crossFetch.fetch;
249320
const res = await _fetch(url, options);
250321
if (!res.ok) {
251322
const errData = unmarshal(await res.text());
@@ -306,3 +377,13 @@ function makeUrl(url: string, args: unknown) {
306377
}
307378
return result;
308379
}
380+
381+
function getOperationFromUrl(url: string) {
382+
const parts = url.split('/');
383+
const r = parts.pop();
384+
if (!r) {
385+
throw new Error(`Invalid URL: ${url}`);
386+
} else {
387+
return r;
388+
}
389+
}

0 commit comments

Comments
 (0)