Skip to main content

prax_typegen/
zod.rs

1//! Zod schema generator.
2
3use convert_case::{Case, Casing};
4use prax_schema::{CompositeType, Enum, Field, FieldType, Model, Schema, TypeModifier, View};
5
6use crate::mapping::TypeMapper;
7
8/// Generates Zod schemas from a Prax schema.
9pub struct ZodGenerator;
10
11impl ZodGenerator {
12    /// Generate all Zod schemas for the given schema.
13    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
192/// Resolve the full Zod expression for a field (including nullable/array wrappers).
193fn 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
204/// Resolve the base Zod type (without modifier wrappers).
205fn 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}