1use convert_case::{Case, Casing};
4use prax_schema::{CompositeType, Enum, Field, FieldType, Model, Schema, TypeModifier, View};
5
6use crate::mapping::TypeMapper;
7
8pub struct ZodGenerator;
10
11impl ZodGenerator {
12 pub fn generate(schema: &Schema) -> String {
14 let mut out = String::with_capacity(4096);
15 out.push_str("// Auto-generated by prax-typegen. Do not edit.\n\n");
16 out.push_str("import { z } from 'zod';\n\n");
17
18 for (_, enum_def) in &schema.enums {
19 Self::write_enum(&mut out, enum_def);
20 out.push('\n');
21 }
22
23 for (_, composite) in &schema.types {
24 Self::write_composite(&mut out, composite, schema);
25 out.push('\n');
26 }
27
28 for (_, model) in &schema.models {
29 Self::write_model(&mut out, model, schema);
30 out.push('\n');
31 }
32
33 for (_, view) in &schema.views {
34 Self::write_view(&mut out, view, schema);
35 out.push('\n');
36 }
37
38 out
39 }
40
41 fn write_enum(out: &mut String, enum_def: &Enum) {
42 let name = enum_def.name();
43 let variants: Vec<String> = enum_def
44 .variants
45 .iter()
46 .map(|v| format!("'{}'", v.db_value()))
47 .collect();
48
49 out.push_str(&format!(
50 "export const {name}Schema = z.enum([{}]);\n",
51 variants.join(", ")
52 ));
53 out.push_str(&format!(
54 "export type {name} = z.infer<typeof {name}Schema>;\n"
55 ));
56 }
57
58 fn write_model(out: &mut String, model: &Model, schema: &Schema) {
59 let name = model.name();
60 out.push_str(&format!("export const {name}Schema = z.object({{\n"));
61
62 for (_, field) in &model.fields {
63 if field.is_relation() {
64 Self::write_relation_field(out, field);
65 } else {
66 Self::write_field(out, field, schema);
67 }
68 }
69
70 out.push_str("});\n");
71 out.push_str(&format!(
72 "export type {name} = z.infer<typeof {name}Schema>;\n"
73 ));
74
75 Self::write_create_schema(out, model, schema);
76 Self::write_update_schema(out, model, schema);
77 }
78
79 fn write_view(out: &mut String, view: &View, schema: &Schema) {
80 let name = view.name();
81 out.push_str(&format!("export const {name}Schema = z.object({{\n"));
82
83 for (_, field) in &view.fields {
84 Self::write_field(out, field, schema);
85 }
86
87 out.push_str("});\n");
88 out.push_str(&format!(
89 "export type {name} = z.infer<typeof {name}Schema>;\n"
90 ));
91 }
92
93 fn write_composite(out: &mut String, composite: &CompositeType, schema: &Schema) {
94 let name = composite.name();
95 out.push_str(&format!("export const {name}Schema = z.object({{\n"));
96
97 for (_, field) in &composite.fields {
98 Self::write_field(out, field, schema);
99 }
100
101 out.push_str("});\n");
102 out.push_str(&format!(
103 "export type {name} = z.infer<typeof {name}Schema>;\n"
104 ));
105 }
106
107 fn write_field(out: &mut String, field: &Field, schema: &Schema) {
108 let name = field.name().to_case(Case::Camel);
109 let zod_expr = resolve_zod_type(field, schema);
110 out.push_str(&format!(" {name}: {zod_expr},\n"));
111 }
112
113 fn write_relation_field(out: &mut String, field: &Field) {
114 let name = field.name().to_case(Case::Camel);
115 let type_name = field.field_type.type_name();
116 let schema_ref = format!("z.lazy(() => {type_name}Schema)");
117
118 let expr = if field.modifier.is_list() {
119 format!("{schema_ref}.array()")
120 } else if field.modifier.is_optional() {
121 format!("{schema_ref}.nullable()")
122 } else {
123 schema_ref
124 };
125
126 out.push_str(&format!(" {name}: {expr},\n"));
127 }
128
129 fn write_create_schema(out: &mut String, model: &Model, schema: &Schema) {
130 let name = model.name();
131 out.push_str(&format!(
132 "\nexport const {name}CreateSchema = z.object({{\n"
133 ));
134
135 for (_, field) in &model.fields {
136 if field.is_relation() {
137 continue;
138 }
139 let attrs = field.extract_attributes();
140 if attrs.is_auto || attrs.is_updated_at {
141 continue;
142 }
143
144 let fname = field.name().to_case(Case::Camel);
145 let base_zod = resolve_zod_base(field, schema);
146
147 let is_nullable = field.modifier.is_optional();
148 let is_optional = is_nullable || attrs.default.is_some() || attrs.is_id;
149
150 let expr = match (is_nullable, is_optional) {
151 (true, _) => format!("{base_zod}.nullable().optional()"),
152 (false, true) => format!("{base_zod}.optional()"),
153 _ => base_zod,
154 };
155
156 out.push_str(&format!(" {fname}: {expr},\n"));
157 }
158
159 out.push_str("});\n");
160 out.push_str(&format!(
161 "export type {name}CreateInput = z.infer<typeof {name}CreateSchema>;\n"
162 ));
163 }
164
165 fn write_update_schema(out: &mut String, model: &Model, schema: &Schema) {
166 let name = model.name();
167 out.push_str(&format!(
168 "\nexport const {name}UpdateSchema = z.object({{\n"
169 ));
170
171 for (_, field) in &model.fields {
172 if field.is_relation() {
173 continue;
174 }
175 let attrs = field.extract_attributes();
176 if attrs.is_auto || attrs.is_updated_at {
177 continue;
178 }
179
180 let fname = field.name().to_case(Case::Camel);
181 let base_zod = resolve_zod_base(field, schema);
182 out.push_str(&format!(" {fname}: {base_zod}.optional(),\n"));
183 }
184
185 out.push_str("});\n");
186 out.push_str(&format!(
187 "export type {name}UpdateInput = z.infer<typeof {name}UpdateSchema>;\n"
188 ));
189 }
190}
191
192fn resolve_zod_type(field: &Field, schema: &Schema) -> String {
194 let base = resolve_zod_base(field, schema);
195
196 match field.modifier {
197 TypeModifier::List => format!("{base}.array()"),
198 TypeModifier::Optional => format!("{base}.nullable()"),
199 TypeModifier::OptionalList => format!("{base}.array().nullable()"),
200 TypeModifier::Required => base,
201 }
202}
203
204fn resolve_zod_base(field: &Field, _schema: &Schema) -> String {
206 match &field.field_type {
207 FieldType::Scalar(s) => TypeMapper::zod_type(s).to_string(),
208 FieldType::Enum(name) => format!("{name}Schema"),
209 FieldType::Model(name) => format!("z.lazy(() => {name}Schema)"),
210 FieldType::Composite(name) => format!("{name}Schema"),
211 FieldType::Unsupported(_) => "z.unknown()".to_string(),
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use prax_schema::parse_schema;
219
220 #[test]
221 fn test_generate_zod_model() {
222 let schema = parse_schema(
223 r#"
224 model User {
225 id Int @id @auto
226 email String @unique
227 name String?
228 }
229 "#,
230 )
231 .unwrap();
232
233 let output = ZodGenerator::generate(&schema);
234 assert!(output.contains("import { z } from 'zod';"));
235 assert!(output.contains("export const UserSchema = z.object({"));
236 assert!(output.contains("id: z.number().int(),"));
237 assert!(output.contains("email: z.string(),"));
238 assert!(output.contains("name: z.string().nullable(),"));
239 assert!(output.contains("export type User = z.infer<typeof UserSchema>;"));
240 }
241
242 #[test]
243 fn test_generate_zod_enum() {
244 let schema = parse_schema(
245 r#"
246 enum Role {
247 User
248 Admin
249 }
250 "#,
251 )
252 .unwrap();
253
254 let output = ZodGenerator::generate(&schema);
255 assert!(output.contains("export const RoleSchema = z.enum(['User', 'Admin']);"));
256 assert!(output.contains("export type Role = z.infer<typeof RoleSchema>;"));
257 }
258
259 #[test]
260 fn test_generate_zod_create_schema() {
261 let schema = parse_schema(
262 r#"
263 model Post {
264 id Int @id @auto
265 title String
266 content String?
267 }
268 "#,
269 )
270 .unwrap();
271
272 let output = ZodGenerator::generate(&schema);
273 assert!(output.contains("export const PostCreateSchema = z.object({"));
274 assert!(output.contains("title: z.string(),"));
275 assert!(output.contains("content: z.string().nullable().optional(),"));
276 }
277
278 #[test]
279 fn test_generate_zod_update_schema() {
280 let schema = parse_schema(
281 r#"
282 model User {
283 id Int @id @auto
284 name String
285 }
286 "#,
287 )
288 .unwrap();
289
290 let output = ZodGenerator::generate(&schema);
291 assert!(output.contains("export const UserUpdateSchema = z.object({"));
292 assert!(output.contains("name: z.string().optional(),"));
293 }
294
295 #[test]
296 fn test_generate_zod_relation_lazy() {
297 let schema = parse_schema(
298 r#"
299 model User {
300 id Int @id @auto
301 posts Post[]
302 }
303 model Post {
304 id Int @id @auto
305 authorId Int
306 author User @relation(fields: [authorId], references: [id])
307 }
308 "#,
309 )
310 .unwrap();
311
312 let output = ZodGenerator::generate(&schema);
313 assert!(output.contains("posts: z.lazy(() => PostSchema).array(),"));
314 assert!(output.contains("author: z.lazy(() => UserSchema),"));
315 }
316
317 #[test]
318 fn test_generate_zod_uuid() {
319 let schema = parse_schema(
320 r#"
321 model Token {
322 id Uuid @id
323 value String
324 }
325 "#,
326 )
327 .unwrap();
328
329 let output = ZodGenerator::generate(&schema);
330 assert!(output.contains("id: z.string().uuid(),"));
331 }
332
333 #[test]
334 fn test_generate_zod_list() {
335 let schema = parse_schema(
336 r#"
337 model User {
338 id Int @id @auto
339 tags String[]
340 }
341 "#,
342 )
343 .unwrap();
344
345 let output = ZodGenerator::generate(&schema);
346 assert!(output.contains("tags: z.string().array(),"));
347 }
348}