Skip to content

Commit 9351fc9

Browse files
authored
feat: implement tanstack-query generator plugin (#413)
1 parent 52f44c5 commit 9351fc9

File tree

14 files changed

+1151
-1
lines changed

14 files changed

+1151
-1
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"root": true,
3+
"parser": "@typescript-eslint/parser",
4+
"parserOptions": {
5+
"ecmaVersion": 6,
6+
"sourceType": "module"
7+
},
8+
"plugins": ["@typescript-eslint"],
9+
"extends": [
10+
"eslint:recommended",
11+
"plugin:@typescript-eslint/eslint-recommended",
12+
"plugin:@typescript-eslint/recommended"
13+
]
14+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../../LICENSE
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# ZenStack tanstack-query plugin & runtime
2+
3+
This package contains ZenStack plugin for generating [tanstack-query](https://tanstack.com/query/latest) hooks.
4+
5+
Visit [Homepage](https://zenstack.dev) for more details.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* For a detailed explanation regarding each configuration property and type check, visit:
3+
* https://jestjs.io/docs/configuration
4+
*/
5+
6+
export default {
7+
// Automatically clear mock calls, instances, contexts and results before every test
8+
clearMocks: true,
9+
10+
// Indicates whether the coverage information should be collected while executing the test
11+
collectCoverage: true,
12+
13+
// The directory where Jest should output its coverage files
14+
coverageDirectory: 'tests/coverage',
15+
16+
// An array of regexp pattern strings used to skip coverage collection
17+
coveragePathIgnorePatterns: ['/node_modules/', '/tests/'],
18+
19+
// Indicates which provider should be used to instrument code for coverage
20+
coverageProvider: 'v8',
21+
22+
// A list of reporter names that Jest uses when writing coverage reports
23+
coverageReporters: ['json', 'text', 'lcov', 'clover'],
24+
25+
// A map from regular expressions to paths to transformers
26+
transform: { '^.+\\.tsx?$': 'ts-jest' },
27+
28+
testTimeout: 300000,
29+
};
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"name": "@zenstackhq/tanstack-query",
3+
"displayName": "ZenStack plugin for generating tanstack-query hooks",
4+
"version": "1.0.0-alpha.116",
5+
"description": "ZenStack plugin for generating tanstack-query hooks",
6+
"main": "index.js",
7+
"repository": {
8+
"type": "git",
9+
"url": "https://github.com/zenstackhq/zenstack"
10+
},
11+
"scripts": {
12+
"clean": "rimraf dist",
13+
"build": "pnpm lint && pnpm clean && tsc && copyfiles ./package.json ./README.md ./LICENSE 'res/**/*' dist",
14+
"watch": "tsc --watch",
15+
"lint": "eslint src --ext ts",
16+
"prepublishOnly": "pnpm build",
17+
"publish-dev": "pnpm publish --tag dev"
18+
},
19+
"publishConfig": {
20+
"directory": "dist",
21+
"linkDirectory": true
22+
},
23+
"keywords": [],
24+
"author": "ZenStack Team",
25+
"license": "MIT",
26+
"dependencies": {
27+
"@prisma/generator-helper": "^4.7.1",
28+
"@zenstackhq/sdk": "workspace:*",
29+
"change-case": "^4.1.2",
30+
"decimal.js": "^10.4.2",
31+
"lower-case-first": "^2.0.2",
32+
"superjson": "^1.11.0",
33+
"ts-morph": "^16.0.0",
34+
"upper-case-first": "^2.0.2"
35+
},
36+
"devDependencies": {
37+
"@tanstack/react-query": "^4.29.7",
38+
"@tanstack/svelte-query": "^4.29.7",
39+
"@types/jest": "^29.5.0",
40+
"@types/lower-case-first": "^1.0.1",
41+
"@types/react": "^18.0.26",
42+
"@types/tmp": "^0.2.3",
43+
"@types/upper-case-first": "^1.1.2",
44+
"@zenstackhq/testtools": "workspace:*",
45+
"copyfiles": "^2.4.1",
46+
"jest": "^29.5.0",
47+
"react": "^17.0.2 || ^18",
48+
"react-dom": "^17.0.2 || ^18",
49+
"rimraf": "^3.0.2",
50+
"swr": "^2.0.3",
51+
"ts-jest": "^29.0.5",
52+
"typescript": "^4.9.4"
53+
}
54+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/* eslint-disable */
2+
3+
import {
4+
useMutation,
5+
useQuery,
6+
useQueryClient,
7+
type MutateFunction,
8+
type QueryClient,
9+
type UseMutationOptions,
10+
type UseQueryOptions,
11+
} from '@tanstack/react-query';
12+
import { createContext } from 'react';
13+
14+
/**
15+
* Context for configuring react hooks.
16+
*/
17+
export const RequestHandlerContext = createContext<RequestHandlerContext>({
18+
endpoint: DEFAULT_QUERY_ENDPOINT,
19+
});
20+
21+
/**
22+
* Context provider.
23+
*/
24+
export const Provider = RequestHandlerContext.Provider;
25+
26+
/**
27+
* Creates a react-query query.
28+
*
29+
* @param model The name of the model under query.
30+
* @param url The request URL.
31+
* @param args The request args object, which will be superjson-stringified and appended as "?q=" parameter
32+
* @param options The react-query options object
33+
* @returns useQuery hook
34+
*/
35+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
36+
export function query<R>(model: string, url: string, args?: unknown, options?: UseQueryOptions<R>) {
37+
const reqUrl = makeUrl(url, args);
38+
return useQuery<R>({
39+
queryKey: [QUERY_KEY_PREFIX + model, url, args],
40+
queryFn: () => fetcher<R>(reqUrl),
41+
...options,
42+
});
43+
}
44+
45+
/**
46+
* Creates a POST mutation with react-query.
47+
*
48+
* @param model The name of the model under mutation.
49+
* @param url The request URL.
50+
* @param options The react-query options.
51+
* @param invalidateQueries Whether to invalidate queries after mutation.
52+
* @returns useMutation hooks
53+
*/
54+
export function postMutation<T, R = any>(
55+
model: string,
56+
url: string,
57+
options?: Omit<UseMutationOptions<R, unknown, T>, 'mutationFn'>,
58+
invalidateQueries = true
59+
) {
60+
const queryClient = useQueryClient();
61+
const mutationFn = (data: any) =>
62+
fetcher<R>(url, {
63+
method: 'POST',
64+
headers: {
65+
'content-type': 'application/json',
66+
},
67+
body: marshal(data),
68+
});
69+
70+
const finalOptions = mergeOptions<T, R>(model, options, invalidateQueries, mutationFn, queryClient);
71+
const mutation = useMutation<R, unknown, T>(finalOptions);
72+
return mutation;
73+
}
74+
75+
/**
76+
* Creates a PUT mutation with react-query.
77+
*
78+
* @param model The name of the model under mutation.
79+
* @param url The request URL.
80+
* @param options The react-query options.
81+
* @param invalidateQueries Whether to invalidate queries after mutation.
82+
* @returns useMutation hooks
83+
*/
84+
export function putMutation<T, R = any>(
85+
model: string,
86+
url: string,
87+
options?: Omit<UseMutationOptions<R, unknown, T>, 'mutationFn'>,
88+
invalidateQueries = true
89+
) {
90+
const queryClient = useQueryClient();
91+
const mutationFn = (data: any) =>
92+
fetcher<R>(url, {
93+
method: 'PUT',
94+
headers: {
95+
'content-type': 'application/json',
96+
},
97+
body: marshal(data),
98+
});
99+
100+
const finalOptions = mergeOptions<T, R>(model, options, invalidateQueries, mutationFn, queryClient);
101+
const mutation = useMutation<R, unknown, T>(finalOptions);
102+
return mutation;
103+
}
104+
105+
/**
106+
* Creates a DELETE mutation with react-query.
107+
*
108+
* @param model The name of the model under mutation.
109+
* @param url The request URL.
110+
* @param options The react-query options.
111+
* @param invalidateQueries Whether to invalidate queries after mutation.
112+
* @returns useMutation hooks
113+
*/
114+
export function deleteMutation<T, R = any>(
115+
model: string,
116+
url: string,
117+
options?: Omit<UseMutationOptions<R, unknown, T>, 'mutationFn'>,
118+
invalidateQueries = true
119+
) {
120+
const queryClient = useQueryClient();
121+
const mutationFn = (data: any) =>
122+
fetcher<R>(makeUrl(url, data), {
123+
method: 'DELETE',
124+
});
125+
126+
const finalOptions = mergeOptions<T, R>(model, options, invalidateQueries, mutationFn, queryClient);
127+
const mutation = useMutation<R, unknown, T>(finalOptions);
128+
return mutation;
129+
}
130+
131+
function mergeOptions<T, R = any>(
132+
model: string,
133+
options: Omit<UseMutationOptions<R, unknown, T, unknown>, 'mutationFn'> | undefined,
134+
invalidateQueries: boolean,
135+
mutationFn: MutateFunction<R, unknown, T>,
136+
queryClient: QueryClient
137+
): UseMutationOptions<R, unknown, T, unknown> {
138+
const result = { ...options, mutationFn };
139+
if (options?.onSuccess || invalidateQueries) {
140+
result.onSuccess = (...args) => {
141+
if (invalidateQueries) {
142+
queryClient.invalidateQueries([QUERY_KEY_PREFIX + model]);
143+
}
144+
return options?.onSuccess?.(...args);
145+
};
146+
}
147+
return result;
148+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import superjson from 'superjson';
2+
3+
/**
4+
* The default query endpoint.
5+
*/
6+
export const DEFAULT_QUERY_ENDPOINT = '/api/model';
7+
8+
/**
9+
* Prefix for react-query keys.
10+
*/
11+
export const QUERY_KEY_PREFIX = 'zenstack:';
12+
13+
/**
14+
* Context type for configuring the hooks.
15+
*/
16+
export type RequestHandlerContext = {
17+
endpoint: string;
18+
};
19+
20+
/**
21+
* Builds a request URL with optional args.
22+
*/
23+
export function makeUrl(url: string, args: unknown) {
24+
return args ? url + `?q=${encodeURIComponent(marshal(args))}` : url;
25+
}
26+
27+
async function fetcher<R>(url: string, options?: RequestInit) {
28+
const res = await fetch(url, options);
29+
if (!res.ok) {
30+
const error: Error & { info?: unknown; status?: number } = new Error(
31+
'An error occurred while fetching the data.'
32+
);
33+
error.info = unmarshal(await res.text());
34+
error.status = res.status;
35+
throw error;
36+
}
37+
38+
const textResult = await res.text();
39+
try {
40+
return unmarshal(textResult) as R;
41+
} catch (err) {
42+
console.error(`Unable to deserialize data:`, textResult);
43+
throw err;
44+
}
45+
}
46+
47+
export function marshal(value: unknown) {
48+
return superjson.stringify(value);
49+
}
50+
51+
export function unmarshal(value: string) {
52+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
53+
return superjson.parse<any>(value);
54+
}

0 commit comments

Comments
 (0)