Skip to content

Commit

Permalink
feat(zod): 🚀 add zod supported
Browse files Browse the repository at this point in the history
添加 zod 转换支持
  • Loading branch information
charlzyx committed Dec 12, 2024
1 parent d8f8087 commit 666ec71
Show file tree
Hide file tree
Showing 17 changed files with 559 additions and 15 deletions.
Binary file modified bun.lockb
Binary file not shown.
3 changes: 1 addition & 2 deletions packages/formily-schema/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
{
"extends": "../../tsconfig.json",
"include": ["src/**/*"]
"extends": "../../tsconfig.json"
}
2 changes: 1 addition & 1 deletion packages/oas/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test";
import { Project } from "ts-morph";
import { transform } from "../index";
import { transform } from "../src/index";

describe("to-oas transformer", () => {
const project = new Project({});
Expand Down
3 changes: 1 addition & 2 deletions packages/oas/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
{
"extends": "../../tsconfig.json",
"include": ["src/**/*"]
"extends": "../../tsconfig.json"
}
2 changes: 1 addition & 1 deletion packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,4 @@
"publishConfig": {
"access": "public"
}
}
}
10 changes: 5 additions & 5 deletions packages/shared/src/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ const typeIs = (type: Type) => {
"isUndefined",
"isVoid",
].reduce((ll, method) => {
if (type[method]()) {
if (!type) return ll;
if (type[method]?.()) {
ll.push(method.replace("is", ""));
}
return ll;
Expand Down Expand Up @@ -231,10 +232,9 @@ export const debugSymbol = (symbol: Symbol): SymbolInfo => {
export const debugInfo = (type?: Type, typeNode?: Node, symbol?: Symbol) => {
const debug = {
type: type ? debugType(type) : null,
typeSymbol: type.getSymbol() ? debugSymbol(type.getSymbol()) : null,
typeAliasSymbol: type.getAliasSymbol()
? debugSymbol(type.getAliasSymbol())
: null,
typeSymbol: type && type.getSymbol() ? debugSymbol(type.getSymbol()) : null,
typeAliasSymbol:
type && type.getAliasSymbol() ? debugSymbol(type.getAliasSymbol()) : null,
node: typeNode ? debugNode(typeNode) : null,
symbol: symbol ? debugSymbol(symbol) : null,
};
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
// export { debugInfo, debugNode, debugSymbol, debugType } from "./debug";
export { debugInfo, debugNode, debugSymbol, debugType } from "./debug";
export { getNodeExtraInfo } from "./getNodeExtraInfo";
1 change: 0 additions & 1 deletion packages/shared/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
{
"extends": "../../tsconfig.json",
"include": ["src/**/*"]
}
209 changes: 209 additions & 0 deletions packages/zod/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { describe, expect, it } from "bun:test";
import { Project } from "ts-morph";
import { transform } from "../src";

describe("zod schema tests", () => {
const project = new Project({});

it("应该正确处理基本类型 / should handle basic types correctly", () => {
const sourceFile = project.createSourceFile(
"test.ts",
`
class BasicTypes {
str: string;
num: number;
bool: boolean;
nullValue: null;
undefinedValue: undefined;
}
`
);

const result = transform(project);
expect(result).toContain("str: z.string()");
expect(result).toContain("num: z.number()");
expect(result).toContain("bool: z.boolean()");
expect(result).toContain("nullValue: z.null()");
expect(result).toContain("undefinedValue: z.undefined()");

project.removeSourceFile(sourceFile);
});

it("应该正确处理字面量类型 / should handle literal types correctly", () => {
const sourceFile = project.createSourceFile(
"test.ts",
`
class LiteralTypes {
strLiteral: "hello";
numLiteral: 42;
boolLiteral: true;
}
`
);

const result = transform(project);

expect(result).toContain('strLiteral: z.literal("hello")');
expect(result).toContain("numLiteral: z.literal(42)");
expect(result).toContain("boolLiteral: z.literal(true)");

project.removeSourceFile(sourceFile);
});

it("应该正确处理联合类型 / should handle union types correctly", () => {
const sourceFile = project.createSourceFile(
"test.ts",
`
class User {
status: "active" | "inactive";
role: "admin" | "user" | "guest";
}
`
);

const result = transform(project);
expect(result).toContain(
'z.union([z.literal("active"), z.literal("inactive")])'
);
expect(result).toContain(
'z.union([z.literal("admin"), z.literal("user"), z.literal("guest")])'
);

project.removeSourceFile(sourceFile);
});

it("应该正确处理判别联合 / should handle discriminated unions correctly", () => {
const sourceFile = project.createSourceFile(
"test.ts",
`
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; sideLength: number };
class Drawing {
shape: Shape;
}
`
);

const result = transform(project);
expect(result).toContain('z.discriminatedUnion("kind"');
expect(result).toContain("radius: z.number()");
expect(result).toContain("sideLength: z.number()");

project.removeSourceFile(sourceFile);
});

it("应该正确处理交叉类型 / should handle intersection types correctly", () => {
const sourceFile = project.createSourceFile(
"test.ts",
`
interface Named { name: string }
interface Aged { age: number }
class Person {
info: Named & Aged;
}
`
);

const result = transform(project);
expect(result).toContain("z.intersection([");
expect(result).toContain("name: z.string()");
expect(result).toContain("age: z.number()");

project.removeSourceFile(sourceFile);
});

// it("应该正确处理可选和可空属性 / should handle optional and nullable properties correctly", () => {
// /**
// * TODO: 有个小问题
// * https://ts-ast-viewer.com/#code/MYGwhgzhAECqEFMBO0DeAoaXoDswFsEAuaCAFyQEscBzAbk2zBoQH4ScBXfAI2QezQelAPYlyVWtAA+uTiBACmAE2VIEUdqQrUaMuQoYBfIA
// * string | null 节点在 compiler api 中使用 getTypeAtLocation 获取到的节点是 string 类型
// * 而不是 UnionType , 但是 online 页面是对的
// *
// */
// const sourceFile = project.createSourceFile(
// "test.ts",
// `
// class User {
// name: string;
// age?: number;
// bio: string | null;
// address?: string | null;
// }
// `
// );

// const result = transform(project);
// expect(result).toContain("name: z.string()");
// expect(result).toContain("age: z.number().optional()");
// expect(result).toContain("bio: z.string().nullable()");
// expect(result).toContain("address: z.string().nullable().optional()");

// project.removeSourceFile(sourceFile);
// });

it("应该正确处理嵌套对象和数组 / should handle nested objects and arrays correctly", () => {
const sourceFile = project.createSourceFile(
"test.ts",
`
class Address {
street: string;
city: string;
}
class User {
name: string;
address: Address;
tags: string[];
contacts: Address[];
}
`
);

const result = transform(project);
expect(result).toContain("AddressSchema");
expect(result).toContain("street: z.string()");
expect(result).toContain("city: z.string()");
expect(result).toContain("address: AddressSchema");
expect(result).toContain("tags: z.array(z.string())");
expect(result).toContain("contacts: z.array(AddressSchema)");

project.removeSourceFile(sourceFile);
});

it("应该正确处理循环引用 / should handle circular references correctly", () => {
const sourceFile = project.createSourceFile(
"test.ts",
`
class Employee {
name: string;
// 直接循环引用
supervisor?: Employee;
// 嵌套循环引用
team?: {
leader: Employee;
members: Employee[];
};
}
`
);

const result = transform(project);
// 检查是否正确生成了类型声明
expect(result).toContain("EmployeeSchema: z.ZodSchema<EmployeeSchema>");
// 检查是否使用了 lazy 进行循环引用处理
expect(result).toContain("z.lazy(() =>");
// 检查基本字段
expect(result).toContain("name: z.string()");
// 检查可选字段
expect(result).toContain("supervisor: ");
expect(result).toContain("optional()");
// 检查嵌套结构
expect(result).toContain("team: z.object");
expect(result).toContain("members: z.array");

project.removeSourceFile(sourceFile);
});
});
29 changes: 29 additions & 0 deletions packages/zod/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@typeto/zod",
"version": "1.0.0",
"description": "Transform TypeScript to Zod Schema",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/types/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "rollup -c",
"test": "bun test"
},
"dependencies": {
"@typeto/shared": "workspace:*",
"ts-morph": "^19.0.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@rollup/plugin-typescript": "^11.1.6",
"@rollup/plugin-node-resolve": "^15.2.3",
"rollup": "^4.9.6",
"typescript": "^5.3.3"
},
"publishConfig": {
"access": "public"
}
}
23 changes: 23 additions & 0 deletions packages/zod/rollup.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import typescript from "@rollup/plugin-typescript";
import { nodeResolve } from "@rollup/plugin-node-resolve";

export default {
input: "src/index.ts",
output: [
{
file: "dist/index.cjs.js",
format: "cjs",
},
{
file: "dist/index.esm.js",
format: "esm",
},
],
plugins: [
nodeResolve(),
typescript({
tsconfig: "tsconfig.build.json",
}),
],
external: ["ts-morph", "zod"],
};
52 changes: 52 additions & 0 deletions packages/zod/src/entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { ClassDeclaration, Node, Project } from "ts-morph";
import { getNodeExtraInfo } from "@typeto/shared";

// 忽略规则的键
const IgnoreRuleKeys = {
jsDoc: ["ignore", "WIP", "Draft"],
classDecorators: ["ignore", "WIP", "Draft"],
leadingComment: ["@ignore", "@WIP", "@Draft"],
};

// 判断是否应跳过该节点
const shouldSkip = (node: ClassDeclaration) => {
const { decorators, jsDocs, leadingComments } = getNodeExtraInfo(node);

// 检查装饰器中是否有忽略标签
const skipByDecorator =
Object.keys(decorators).findIndex((decoratorName) =>
IgnoreRuleKeys.classDecorators.includes(decoratorName)
) > -1;
if (skipByDecorator) return true;

// 检查 jsDoc 中是否有忽略标签
const skipByJsDoc =
Object.keys(jsDocs).findIndex((tagName) =>
IgnoreRuleKeys.jsDoc.includes(tagName)
) > -1;
if (skipByJsDoc) return true;

// 检查前导注释中是否有忽略标签
const skipByComment =
leadingComments
.join("\n")
.split(/\s+/)
.findIndex((word) => IgnoreRuleKeys.leadingComment.includes(word)) > -1;
if (skipByComment) return true;

return false;
};

export const getEntryNodes = (project: Project) => {
const definitions: ClassDeclaration[] = [];

project.getSourceFiles().forEach((sourceFile) => {
sourceFile.getClasses().forEach((classDec) => {
if (!shouldSkip(classDec)) {
definitions.push(classDec);
}
});
});

return { definitions };
};
9 changes: 9 additions & 0 deletions packages/zod/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Project } from "ts-morph";
import { transformDefinitions } from "./transformer";
import { getEntryNodes } from "./entry";

export const transform = (project: Project) => {
const { definitions } = getEntryNodes(project);
const schema = transformDefinitions(definitions);
return schema;
};
Loading

0 comments on commit 666ec71

Please # to comment.