diff --git a/packages/plugins/swr/.eslintrc.json b/packages/plugins/swr/.eslintrc.json new file mode 100644 index 000000000..0a913e874 --- /dev/null +++ b/packages/plugins/swr/.eslintrc.json @@ -0,0 +1,14 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module" + }, + "plugins": ["@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended" + ] +} diff --git a/packages/plugins/swr/LICENSE b/packages/plugins/swr/LICENSE new file mode 120000 index 000000000..5853aaea5 --- /dev/null +++ b/packages/plugins/swr/LICENSE @@ -0,0 +1 @@ +../../../LICENSE \ No newline at end of file diff --git a/packages/plugins/swr/README.md b/packages/plugins/swr/README.md new file mode 100644 index 000000000..e651fe280 --- /dev/null +++ b/packages/plugins/swr/README.md @@ -0,0 +1,5 @@ +# ZenStack React plugin & runtime + +This package contains ZenStack plugin and runtime for ReactJS. + +Visit [Homepage](https://zenstack.dev) for more details. diff --git a/packages/plugins/swr/jest.config.ts b/packages/plugins/swr/jest.config.ts new file mode 100644 index 000000000..917cf52f6 --- /dev/null +++ b/packages/plugins/swr/jest.config.ts @@ -0,0 +1,29 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +export default { + // Automatically clear mock calls, instances, contexts and results before every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: true, + + // The directory where Jest should output its coverage files + coverageDirectory: 'tests/coverage', + + // An array of regexp pattern strings used to skip coverage collection + coveragePathIgnorePatterns: ['/node_modules/', '/tests/'], + + // Indicates which provider should be used to instrument code for coverage + coverageProvider: 'v8', + + // A list of reporter names that Jest uses when writing coverage reports + coverageReporters: ['json', 'text', 'lcov', 'clover'], + + // A map from regular expressions to paths to transformers + transform: { '^.+\\.tsx?$': 'ts-jest' }, + + testTimeout: 300000, +}; diff --git a/packages/plugins/swr/package.json b/packages/plugins/swr/package.json new file mode 100644 index 000000000..4cdc32406 --- /dev/null +++ b/packages/plugins/swr/package.json @@ -0,0 +1,53 @@ +{ + "name": "@zenstackhq/swr", + "displayName": "ZenStack plugin for generating SWR hooks", + "version": "1.0.0-alpha.116", + "description": "ZenStack plugin for generating SWR hooks", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/zenstackhq/zenstack" + }, + "scripts": { + "clean": "rimraf dist", + "build": "pnpm lint && pnpm clean && tsc && copyfiles ./package.json ./README.md ./LICENSE 'res/**/*' dist", + "watch": "tsc --watch", + "lint": "eslint src --ext ts", + "prepublishOnly": "pnpm build", + "publish-dev": "pnpm publish --tag dev" + }, + "publishConfig": { + "directory": "dist", + "linkDirectory": true + }, + "keywords": [], + "author": "ZenStack Team", + "license": "MIT", + "dependencies": { + "@prisma/generator-helper": "^4.7.1", + "@zenstackhq/sdk": "workspace:*", + "change-case": "^4.1.2", + "decimal.js": "^10.4.2", + "lower-case-first": "^2.0.2", + "superjson": "^1.11.0", + "ts-morph": "^16.0.0", + "upper-case-first": "^2.0.2" + }, + "devDependencies": { + "@tanstack/react-query": "^4.28.0", + "@types/jest": "^29.5.0", + "@types/lower-case-first": "^1.0.1", + "@types/react": "^18.0.26", + "@types/tmp": "^0.2.3", + "@types/upper-case-first": "^1.1.2", + "@zenstackhq/testtools": "workspace:*", + "copyfiles": "^2.4.1", + "jest": "^29.5.0", + "react": "^17.0.2 || ^18", + "react-dom": "^17.0.2 || ^18", + "rimraf": "^3.0.2", + "swr": "^2.0.3", + "ts-jest": "^29.0.5", + "typescript": "^4.9.4" + } +} diff --git a/packages/plugins/swr/res/helper.ts b/packages/plugins/swr/res/helper.ts new file mode 100644 index 000000000..38c2d00b8 --- /dev/null +++ b/packages/plugins/swr/res/helper.ts @@ -0,0 +1,147 @@ +import { createContext } from 'react'; +import type { MutatorCallback, MutatorOptions, SWRResponse } from 'swr'; +import useSWR, { useSWRConfig } from 'swr'; + +/** + * Context type for configuring react hooks. + */ +export type RequestHandlerContext = { + endpoint: string; +}; + +/** + * Context for configuring react hooks. + */ +export const RequestHandlerContext = createContext({ + endpoint: '/api/model', +}); + +/** + * Context provider. + */ +export const Provider = RequestHandlerContext.Provider; + +/** + * Client request options + */ +export type RequestOptions = { + // disable data fetching + disabled?: boolean; + initialData?: T; +}; + +/** + * Makes a GET request with SWR. + * + * @param url The request URL. + * @param args The request args object, which will be superjson-stringified and appended as "?q=" parameter + * @returns SWR response + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function get( + url: string | null, + args?: unknown, + options?: RequestOptions +): SWRResponse { + const reqUrl = options?.disabled ? null : url ? makeUrl(url, args) : null; + return useSWR(reqUrl, fetcher, { + fallbackData: options?.initialData, + }); +} + +/** + * Makes a POST request. + * + * @param url The request URL. + * @param data The request data. + * @param mutate Mutator for invalidating cache. + */ +export async function post(url: string, data: Data, mutate: Mutator): Promise { + const r: Result = await fetcher(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: marshal(data), + }); + mutate(); + return r; +} + +/** + * Makes a PUT request. + * + * @param url The request URL. + * @param data The request data. + * @param mutate Mutator for invalidating cache. + */ +export async function put(url: string, data: Data, mutate: Mutator): Promise { + const r: Result = await fetcher(url, { + method: 'PUT', + headers: { + 'content-type': 'application/json', + }, + body: marshal(data), + }); + mutate(); + return r; +} + +/** + * Makes a DELETE request. + * + * @param url The request URL. + * @param args The request args object, which will be superjson-stringified and appended as "?q=" parameter + * @param mutate Mutator for invalidating cache. + */ +export async function del(url: string, args: unknown, mutate: Mutator): Promise { + const reqUrl = makeUrl(url, args); + const r: Result = await fetcher(reqUrl, { + method: 'DELETE', + }); + const path = url.split('/'); + path.pop(); + mutate(); + return r; +} + +type Mutator = ( + data?: unknown | Promise | MutatorCallback, + opts?: boolean | MutatorOptions +) => Promise; + +export function getMutate(prefixes: string[]): Mutator { + // https://swr.vercel.app/docs/advanced/cache#mutate-multiple-keys-from-regex + const { cache, mutate } = useSWRConfig(); + return (data?: unknown | Promise | MutatorCallback, opts?: boolean | MutatorOptions) => { + if (!(cache instanceof Map)) { + throw new Error('mutate requires the cache provider to be a Map instance'); + } + + const keys = Array.from(cache.keys()).filter( + (k) => typeof k === 'string' && prefixes.some((prefix) => k.startsWith(prefix)) + ) as string[]; + const mutations = keys.map((key) => mutate(key, data, opts)); + return Promise.all(mutations); + }; +} + +export async function fetcher(url: string, options?: RequestInit) { + const res = await fetch(url, options); + if (!res.ok) { + const error: Error & { info?: unknown; status?: number } = new Error( + 'An error occurred while fetching the data.' + ); + error.info = unmarshal(await res.text()); + error.status = res.status; + throw error; + } + + const textResult = await res.text(); + try { + return unmarshal(textResult) as R; + } catch (err) { + console.error(`Unable to deserialize data:`, textResult); + throw err; + } +} diff --git a/packages/plugins/swr/res/marshal-json.ts b/packages/plugins/swr/res/marshal-json.ts new file mode 100644 index 000000000..1f00abc79 --- /dev/null +++ b/packages/plugins/swr/res/marshal-json.ts @@ -0,0 +1,12 @@ +function marshal(value: unknown) { + return JSON.stringify(value); +} + +function unmarshal(value: string) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return JSON.parse(value) as any; +} + +function makeUrl(url: string, args: unknown) { + return args ? url + `?q=${encodeURIComponent(JSON.stringify(args))}` : url; +} diff --git a/packages/plugins/swr/res/marshal-superjson.ts b/packages/plugins/swr/res/marshal-superjson.ts new file mode 100644 index 000000000..559b11b4e --- /dev/null +++ b/packages/plugins/swr/res/marshal-superjson.ts @@ -0,0 +1,20 @@ +import superjson from 'superjson'; + +function marshal(value: unknown) { + return superjson.stringify(value); +} + +function unmarshal(value: string) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const j = JSON.parse(value) as any; + if (j?.json) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return superjson.parse(value); + } else { + return j; + } +} + +function makeUrl(url: string, args: unknown) { + return args ? url + `?q=${encodeURIComponent(superjson.stringify(args))}` : url; +} diff --git a/packages/plugins/swr/src/generator.ts b/packages/plugins/swr/src/generator.ts new file mode 100644 index 000000000..bf96f47c4 --- /dev/null +++ b/packages/plugins/swr/src/generator.ts @@ -0,0 +1,518 @@ +import { DMMF } from '@prisma/generator-helper'; +import { + CrudFailureReason, + PluginError, + PluginOptions, + createProject, + getDataModels, + saveProject, +} from '@zenstackhq/sdk'; +import { DataModel, Model } from '@zenstackhq/sdk/ast'; +import { paramCase } from 'change-case'; +import fs from 'fs'; +import { lowerCaseFirst } from 'lower-case-first'; +import path from 'path'; +import { Project } from 'ts-morph'; + +export async function generate(model: Model, options: PluginOptions, dmmf: DMMF.Document) { + let outDir = options.output as string; + if (!outDir) { + throw new PluginError('"output" option is required'); + } + + if (!path.isAbsolute(outDir)) { + // output dir is resolved relative to the schema file path + outDir = path.join(path.dirname(options.schemaPath), outDir); + } + + const project = createProject(); + const warnings: string[] = []; + const models = getDataModels(model); + + generateIndex(project, outDir, models); + generateHelper(project, outDir, options.useSuperJson === true); + + models.forEach((dataModel) => { + const mapping = dmmf.mappings.modelOperations.find((op) => op.model === dataModel.name); + if (!mapping) { + warnings.push(`Unable to find mapping for model ${dataModel.name}`); + return; + } + generateModelHooks(project, outDir, dataModel, mapping); + }); + + await saveProject(project); + return warnings; +} + +function wrapReadbackErrorCheck(code: string) { + return `try { + ${code} + } catch (err: any) { + if (err.info?.prisma && err.info?.code === 'P2004' && err.info?.reason === '${CrudFailureReason.RESULT_NOT_READABLE}') { + // unable to readback data + return undefined; + } else { + throw err; + } + }`; +} + +function generateModelHooks(project: Project, outDir: string, model: DataModel, mapping: DMMF.ModelMapping) { + const fileName = paramCase(model.name); + const sf = project.createSourceFile(path.join(outDir, `${fileName}.ts`), undefined, { overwrite: true }); + + sf.addStatements('/* eslint-disable */'); + + sf.addImportDeclaration({ + namedImports: ['Prisma', model.name], + isTypeOnly: true, + moduleSpecifier: '@prisma/client', + }); + sf.addStatements([ + `import { useContext } from 'react';`, + `import { type RequestOptions, RequestHandlerContext } from './_helper';`, + `import * as request from './_helper';`, + ]); + + const useFunc = sf.addFunction({ + name: `use${model.name}`, + isExported: true, + }); + + const prefixesToMutate = ['find', 'aggregate', 'count', 'groupBy']; + const modelRouteName = lowerCaseFirst(model.name); + + useFunc.addStatements([ + 'const { endpoint } = useContext(RequestHandlerContext);', + `const prefixesToMutate = [${prefixesToMutate + .map((prefix) => '`${endpoint}/' + modelRouteName + '/' + prefix + '`') + .join(', ')}];`, + 'const mutate = request.getMutate(prefixesToMutate);', + ]); + + const methods: string[] = []; + + // create is somehow named "createOne" in the DMMF + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (mapping.create || (mapping as any).createOne) { + methods.push('create'); + const argsType = `Prisma.${model.name}CreateArgs`; + const inputType = `Prisma.SelectSubset`; + const returnType = `Prisma.CheckSelect>`; + useFunc + .addFunction({ + name: 'create', + isAsync: true, + typeParameters: [`T extends ${argsType}`], + parameters: [ + { + name: 'args', + type: inputType, + }, + ], + }) + .addBody() + .addStatements([ + wrapReadbackErrorCheck( + `return await request.post<${inputType}, ${returnType}>(\`\${endpoint}/${modelRouteName}/create\`, args, mutate);` + ), + ]); + } + + // createMany + if (mapping.createMany) { + methods.push('createMany'); + const argsType = `Prisma.${model.name}CreateManyArgs`; + const inputType = `Prisma.SelectSubset`; + const returnType = `Prisma.BatchPayload`; + useFunc + .addFunction({ + name: 'createMany', + isAsync: true, + typeParameters: [`T extends ${argsType}`], + parameters: [ + { + name: 'args', + type: inputType, + }, + ], + }) + .addBody() + .addStatements([ + `return await request.post<${inputType}, ${returnType}>(\`\${endpoint}/${modelRouteName}/createMany\`, args, mutate);`, + ]); + } + + // findMany + if (mapping.findMany) { + methods.push('findMany'); + const argsType = `Prisma.${model.name}FindManyArgs`; + const inputType = `Prisma.SelectSubset`; + const returnType = `Array>`; + useFunc + .addFunction({ + name: 'findMany', + typeParameters: [`T extends ${argsType}`], + parameters: [ + { + name: 'args?', + type: inputType, + }, + { + name: 'options?', + type: `RequestOptions<${returnType}>`, + }, + ], + }) + .addBody() + .addStatements([ + `return request.get<${returnType}>(\`\${endpoint}/${modelRouteName}/findMany\`, args, options);`, + ]); + } + + // findUnique + if (mapping.findUnique) { + methods.push('findUnique'); + const argsType = `Prisma.${model.name}FindUniqueArgs`; + const inputType = `Prisma.SelectSubset`; + const returnType = `Prisma.${model.name}GetPayload`; + useFunc + .addFunction({ + name: 'findUnique', + typeParameters: [`T extends ${argsType}`], + parameters: [ + { + name: 'args', + type: inputType, + }, + { + name: 'options?', + type: `RequestOptions<${returnType}>`, + }, + ], + }) + .addBody() + .addStatements([ + `return request.get<${returnType}>(\`\${endpoint}/${modelRouteName}/findUnique\`, args, options);`, + ]); + } + + // findFirst + if (mapping.findFirst) { + methods.push('findFirst'); + const argsType = `Prisma.${model.name}FindFirstArgs`; + const inputType = `Prisma.SelectSubset`; + const returnType = `Prisma.${model.name}GetPayload`; + useFunc + .addFunction({ + name: 'findFirst', + typeParameters: [`T extends ${argsType}`], + parameters: [ + { + name: 'args', + type: inputType, + }, + { + name: 'options?', + type: `RequestOptions<${returnType}>`, + }, + ], + }) + .addBody() + .addStatements([ + `return request.get<${returnType}>(\`\${endpoint}/${modelRouteName}/findFirst\`, args, options);`, + ]); + } + + // update + // update is somehow named "updateOne" in the DMMF + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (mapping.update || (mapping as any).updateOne) { + methods.push('update'); + const argsType = `Prisma.${model.name}UpdateArgs`; + const inputType = `Prisma.SelectSubset`; + const returnType = `Prisma.${model.name}GetPayload`; + useFunc + .addFunction({ + name: 'update', + isAsync: true, + typeParameters: [`T extends ${argsType}`], + parameters: [ + { + name: 'args', + type: inputType, + }, + ], + }) + .addBody() + .addStatements([ + wrapReadbackErrorCheck( + `return await request.put<${inputType}, ${returnType}>(\`\${endpoint}/${modelRouteName}/update\`, args, mutate);` + ), + ]); + } + + // updateMany + if (mapping.updateMany) { + methods.push('updateMany'); + const argsType = `Prisma.${model.name}UpdateManyArgs`; + const inputType = `Prisma.SelectSubset`; + const returnType = `Prisma.BatchPayload`; + useFunc + .addFunction({ + name: 'updateMany', + isAsync: true, + typeParameters: [`T extends ${argsType}`], + parameters: [ + { + name: 'args', + type: inputType, + }, + ], + }) + .addBody() + .addStatements([ + `return await request.put<${inputType}, ${returnType}>(\`\${endpoint}/${modelRouteName}/updateMany\`, args, mutate);`, + ]); + } + + // upsert + // upsert is somehow named "upsertOne" in the DMMF + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (mapping.upsert || (mapping as any).upsertOne) { + methods.push('upsert'); + const argsType = `Prisma.${model.name}UpsertArgs`; + const inputType = `Prisma.SelectSubset`; + const returnType = `Prisma.${model.name}GetPayload`; + useFunc + .addFunction({ + name: 'upsert', + isAsync: true, + typeParameters: [`T extends ${argsType}`], + parameters: [ + { + name: 'args', + type: inputType, + }, + ], + }) + .addBody() + .addStatements([ + wrapReadbackErrorCheck( + `return await request.post<${inputType}, ${returnType}>(\`\${endpoint}/${modelRouteName}/upsert\`, args, mutate);` + ), + ]); + } + + // del + // delete is somehow named "deleteOne" in the DMMF + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (mapping.delete || (mapping as any).deleteOne) { + methods.push('del'); + const argsType = `Prisma.${model.name}DeleteArgs`; + const inputType = `Prisma.SelectSubset`; + const returnType = `Prisma.${model.name}GetPayload`; + useFunc + .addFunction({ + name: 'del', + isAsync: true, + typeParameters: [`T extends ${argsType}`], + parameters: [ + { + name: 'args', + type: inputType, + }, + ], + }) + .addBody() + .addStatements([ + wrapReadbackErrorCheck( + `return await request.del<${returnType}>(\`\${endpoint}/${modelRouteName}/delete\`, args, mutate);` + ), + ]); + } + + // deleteMany + if (mapping.deleteMany) { + methods.push('deleteMany'); + const argsType = `Prisma.${model.name}DeleteManyArgs`; + const inputType = `Prisma.SelectSubset`; + const returnType = `Prisma.BatchPayload`; + useFunc + .addFunction({ + name: 'deleteMany', + isAsync: true, + typeParameters: [`T extends ${argsType}`], + parameters: [ + { + name: 'args?', + type: inputType, + }, + ], + }) + .addBody() + .addStatements([ + `return await request.del<${returnType}>(\`\${endpoint}/${modelRouteName}/deleteMany\`, args, mutate);`, + ]); + } + + // aggregate + if (mapping.aggregate) { + methods.push('aggregate'); + const argsType = `Prisma.${model.name}AggregateArgs`; + const inputType = `Prisma.Subset`; + const returnType = `Prisma.Get${model.name}AggregateType`; + useFunc + .addFunction({ + name: 'aggregate', + typeParameters: [`T extends ${argsType}`], + parameters: [ + { + name: 'args', + type: inputType, + }, + { + name: 'options?', + type: `RequestOptions<${returnType}>`, + }, + ], + }) + .addBody() + .addStatements([ + `return request.get<${returnType}>(\`\${endpoint}/${modelRouteName}/aggregate\`, args, options);`, + ]); + } + + // groupBy + if (mapping.groupBy) { + methods.push('groupBy'); + const returnType = `{} extends InputErrors ? + Array & + { + [P in ((keyof T) & (keyof Prisma.${model.name}GroupByOutputType))]: P extends '_count' + ? T[P] extends boolean + ? number + : Prisma.GetScalarType + : Prisma.GetScalarType + } + > : InputErrors`; + useFunc + .addFunction({ + name: 'groupBy', + typeParameters: [ + `T extends Prisma.${model.name}GroupByArgs`, + `HasSelectOrTake extends Prisma.Or>, Prisma.Extends<'take', Prisma.Keys>>`, + `OrderByArg extends Prisma.True extends HasSelectOrTake ? { orderBy: Prisma.${model.name}GroupByArgs['orderBy'] }: { orderBy?: Prisma.${model.name}GroupByArgs['orderBy'] },`, + `OrderFields extends Prisma.ExcludeUnderscoreKeys>>`, + `ByFields extends Prisma.TupleToUnion`, + `ByValid extends Prisma.Has`, + `HavingFields extends Prisma.GetHavingFields`, + `HavingValid extends Prisma.Has`, + `ByEmpty extends T['by'] extends never[] ? Prisma.True : Prisma.False`, + `InputErrors extends ByEmpty extends Prisma.True + ? \`Error: "by" must not be empty.\` + : HavingValid extends Prisma.False + ? { + [P in HavingFields]: P extends ByFields + ? never + : P extends string + ? \`Error: Field "\${P}" used in "having" needs to be provided in "by".\` + : [ + Error, + 'Field ', + P, + \` in "having" needs to be provided in "by"\`, + ] + }[HavingFields] + : 'take' extends Prisma.Keys + ? 'orderBy' extends Prisma.Keys + ? ByValid extends Prisma.True + ? {} + : { + [P in OrderFields]: P extends ByFields + ? never + : \`Error: Field "\${P}" in "orderBy" needs to be provided in "by"\` + }[OrderFields] + : 'Error: If you provide "take", you also need to provide "orderBy"' + : 'skip' extends Prisma.Keys + ? 'orderBy' extends Prisma.Keys + ? ByValid extends Prisma.True + ? {} + : { + [P in OrderFields]: P extends ByFields + ? never + : \`Error: Field "\${P}" in "orderBy" needs to be provided in "by"\` + }[OrderFields] + : 'Error: If you provide "skip", you also need to provide "orderBy"' + : ByValid extends Prisma.True + ? {} + : { + [P in OrderFields]: P extends ByFields + ? never + : \`Error: Field "\${P}" in "orderBy" needs to be provided in "by"\` + }[OrderFields]`, + ], + parameters: [ + { + name: 'args', + type: `Prisma.SubsetIntersection & InputErrors`, + }, + { + name: 'options?', + type: `RequestOptions<${returnType}>`, + }, + ], + }) + .addBody() + .addStatements([ + `return request.get<${returnType}>(\`\${endpoint}/${modelRouteName}/groupBy\`, args, options);`, + ]); + } + + // somehow dmmf doesn't contain "count" operation, so we unconditionally add it here + { + methods.push('count'); + const argsType = `Prisma.${model.name}CountArgs`; + const inputType = `Prisma.Subset`; + const returnType = `T extends { select: any; } ? T['select'] extends true ? number : Prisma.GetScalarType : number`; + useFunc + .addFunction({ + name: 'count', + typeParameters: [`T extends ${argsType}`], + parameters: [ + { + name: 'args', + type: inputType, + }, + { + name: 'options?', + type: `RequestOptions<${returnType}>`, + }, + ], + }) + .addBody() + .addStatements([ + `return request.get<${returnType}>(\`\${endpoint}/${modelRouteName}/count\`, args, options);`, + ]); + } + + useFunc.addStatements([`return { ${methods.join(', ')} };`]); +} + +function generateIndex(project: Project, outDir: string, models: DataModel[]) { + const sf = project.createSourceFile(path.join(outDir, 'index.ts'), undefined, { overwrite: true }); + sf.addStatements(models.map((d) => `export * from './${paramCase(d.name)}';`)); + sf.addStatements(`export * from './_helper';`); +} + +function generateHelper(project: Project, outDir: string, useSuperJson: boolean) { + const helperContent = fs.readFileSync(path.join(__dirname, './res/helper.ts'), 'utf-8'); + const marshalContent = fs.readFileSync( + path.join(__dirname, useSuperJson ? './res/marshal-superjson.ts' : './res/marshal-json.ts'), + 'utf-8' + ); + project.createSourceFile(path.join(outDir, '_helper.ts'), `${helperContent}\n${marshalContent}`, { + overwrite: true, + }); +} diff --git a/packages/plugins/swr/src/index.ts b/packages/plugins/swr/src/index.ts new file mode 100644 index 000000000..43731f984 --- /dev/null +++ b/packages/plugins/swr/src/index.ts @@ -0,0 +1,10 @@ +import type { DMMF } from '@prisma/generator-helper'; +import type { PluginOptions } from '@zenstackhq/sdk'; +import type { Model } from '@zenstackhq/sdk/ast'; +import { generate } from './generator'; + +export const name = 'SWR'; + +export default async function run(model: Model, options: PluginOptions, dmmf: DMMF.Document) { + return generate(model, options, dmmf); +} diff --git a/packages/plugins/swr/tests/swr.test.ts b/packages/plugins/swr/tests/swr.test.ts new file mode 100644 index 000000000..9b68dedfe --- /dev/null +++ b/packages/plugins/swr/tests/swr.test.ts @@ -0,0 +1,77 @@ +/// + +import { loadSchema } from '@zenstackhq/testtools'; + +describe('SWR Plugin Tests', () => { + let origDir: string; + + beforeAll(() => { + origDir = process.cwd(); + }); + + afterEach(() => { + process.chdir(origDir); + }); + + const sharedModel = ` +model User { + id String @id + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + email String @unique + role String @default('USER') + posts Post[] +} + +model Post { + id String @id + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + title String + author User? @relation(fields: [authorId], references: [id]) + authorId String? + published Boolean @default(false) + viewCount Int @default(0) +} + +model Foo { + id String @id + @@ignore +} + `; + + it('swr generator regular json', async () => { + await loadSchema( + ` +plugin swr { + provider = '${process.cwd()}/dist' + output = '$projectRoot/hooks' +} + +${sharedModel} + `, + true, + false, + [`${origDir}/dist`, 'react', '@types/react', 'swr'], + true + ); + }); + + it('swr generator superjson', async () => { + await loadSchema( + ` +plugin swr { + provider = '${process.cwd()}/dist' + output = '$projectRoot/hooks' + useSuperJson = true +} + +${sharedModel} + `, + true, + false, + [`${origDir}/dist`, 'react', '@types/react', 'swr', 'superjson'], + true + ); + }); +}); diff --git a/packages/plugins/swr/tsconfig.json b/packages/plugins/swr/tsconfig.json new file mode 100644 index 000000000..e2c5bd0f3 --- /dev/null +++ b/packages/plugins/swr/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES6", + "module": "CommonJS", + "lib": ["ESNext", "DOM"], + "sourceMap": true, + "outDir": "dist", + "strict": true, + "noUnusedLocals": true, + "noImplicitReturns": true, + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "resolveJsonModule": true, + "strictPropertyInitialization": false, + "paths": {} + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/plugins/tanstack-query/src/runtime/index.ts.bak b/packages/plugins/tanstack-query/src/runtime/index.ts.bak deleted file mode 100644 index e1ff8d2d0..000000000 --- a/packages/plugins/tanstack-query/src/runtime/index.ts.bak +++ /dev/null @@ -1,20 +0,0 @@ -import { createContext } from 'react'; - -/** - * Context type for configuring react hooks. - */ -export type RequestHandlerContext = { - endpoint: string; -}; - -/** - * Context for configuring react hooks. - */ -export const RequestHandlerContext = createContext({ - endpoint: '/api/model', -}); - -/** - * Context provider. - */ -export const Provider = RequestHandlerContext.Provider; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afca58271..0d8815d82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -244,6 +244,80 @@ importers: version: 4.9.4 publishDirectory: dist + packages/plugins/swr: + dependencies: + '@prisma/generator-helper': + specifier: ^4.7.1 + version: 4.7.1 + '@zenstackhq/sdk': + specifier: workspace:* + version: link:../../sdk/dist + change-case: + specifier: ^4.1.2 + version: 4.1.2 + decimal.js: + specifier: ^10.4.2 + version: 10.4.2 + lower-case-first: + specifier: ^2.0.2 + version: 2.0.2 + superjson: + specifier: ^1.11.0 + version: 1.12.1 + ts-morph: + specifier: ^16.0.0 + version: 16.0.0 + upper-case-first: + specifier: ^2.0.2 + version: 2.0.2 + devDependencies: + '@tanstack/react-query': + specifier: ^4.28.0 + version: 4.29.7(react-dom@18.2.0)(react@18.2.0) + '@types/jest': + specifier: ^29.5.0 + version: 29.5.0 + '@types/lower-case-first': + specifier: ^1.0.1 + version: 1.0.1 + '@types/react': + specifier: ^18.0.26 + version: 18.0.26 + '@types/tmp': + specifier: ^0.2.3 + version: 0.2.3 + '@types/upper-case-first': + specifier: ^1.1.2 + version: 1.1.2 + '@zenstackhq/testtools': + specifier: workspace:* + version: link:../../testtools/dist + copyfiles: + specifier: ^2.4.1 + version: 2.4.1 + jest: + specifier: ^29.5.0 + version: 29.5.0 + react: + specifier: ^17.0.2 || ^18 + version: 18.2.0 + react-dom: + specifier: ^17.0.2 || ^18 + version: 18.2.0(react@18.2.0) + rimraf: + specifier: ^3.0.2 + version: 3.0.2 + swr: + specifier: ^2.0.3 + version: 2.0.3(react@18.2.0) + ts-jest: + specifier: ^29.0.5 + version: 29.0.5(@babel/core@7.20.5)(jest@29.5.0)(typescript@4.9.5) + typescript: + specifier: ^4.9.4 + version: 4.9.5 + publishDirectory: dist + packages/plugins/tanstack-query: dependencies: '@prisma/generator-helper':