Skip to main content

prax_typegen/
typescript.rs

1//! TypeScript interface generator.
2
3use convert_case::{Case, Casing};
4use prax_schema::{CompositeType, Enum, Field, FieldType, Model, Schema, View};
5
6use crate::mapping::TypeMapper;
7
8/// Generates TypeScript interfaces from a Prax schema.
9pub struct InterfaceGenerator;
10
11impl InterfaceGenerator {
12    /// Generate all TypeScript interfaces 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
17        for (_, enum_def) in &schema.enums {
18            Self::write_enum(&mut out, enum_def);
19            out.push('\n');
20        }
21
22        for (_, composite) in &schema.types {
23            Self::write_composite(&mut out, composite, schema);
24            out.push('\n');
25        }
26
27        for (_, model) in &schema.models {
28            Self::write_model(&mut out, model, schema);
29            out.push('\n');
30        }
31
32        for (_, view) in &schema.views {
33            Self::write_view(&mut out, view, schema);
34            out.push('\n');
35        }
36
37        out
38    }
39
40    fn write_enum(out: &mut String, enum_def: &Enum) {
41        if let Some(doc) = &enum_def.documentation {
42            write_jsdoc(out, &doc.text, 0);
43        }
44        out.push_str(&format!("export enum {} {{\n", enum_def.name()));
45        for variant in &enum_def.variants {
46            let db_val = variant.db_value();
47            out.push_str(&format!("  {} = '{}',\n", variant.name(), db_val));
48        }
49        out.push_str("}\n");
50    }
51
52    fn write_model(out: &mut String, model: &Model, schema: &Schema) {
53        if let Some(doc) = &model.documentation {
54            write_jsdoc(out, &doc.text, 0);
55        }
56        out.push_str(&format!("export interface {} {{\n", model.name()));
57
58        for (_, field) in &model.fields {
59            if field.is_relation() {
60                Self::write_relation_field(out, field, schema);
61            } else {
62                Self::write_field(out, field, schema);
63            }
64        }
65
66        out.push_str("}\n");
67
68        Self::write_create_input(out, model, schema);
69        Self::write_update_input(out, model, schema);
70    }
71
72    fn write_view(out: &mut String, view: &View, schema: &Schema) {
73        if let Some(doc) = &view.documentation {
74            write_jsdoc(out, &doc.text, 0);
75        }
76        out.push_str(&format!("export interface {} {{\n", view.name()));
77
78        for (_, field) in &view.fields {
79            Self::write_field(out, field, schema);
80        }
81
82        out.push_str("}\n");
83    }
84
85    fn write_composite(out: &mut String, composite: &CompositeType, schema: &Schema) {
86        if let Some(doc) = &composite.documentation {
87            write_jsdoc(out, &doc.text, 0);
88        }
89        out.push_str(&format!("export interface {} {{\n", composite.name()));
90
91        for (_, field) in &composite.fields {
92            Self::write_field(out, field, schema);
93        }
94
95        out.push_str("}\n");
96    }
97
98    fn write_field(out: &mut String, field: &Field, schema: &Schema) {
99        let name = field.name().to_case(Case::Camel);
100        let optional = field.modifier.is_optional();
101        let ts_type = resolve_ts_type(field, schema);
102
103        let opt_mark = if optional { "?" } else { "" };
104        out.push_str(&format!("  {name}{opt_mark}: {ts_type};\n"));
105    }
106
107    fn write_relation_field(out: &mut String, field: &Field, _schema: &Schema) {
108        let name = field.name().to_case(Case::Camel);
109        let type_name = field.field_type.type_name();
110        let optional = field.modifier.is_optional();
111        let is_list = field.modifier.is_list();
112
113        let ts = if is_list {
114            format!("{type_name}[]")
115        } else {
116            type_name.to_string()
117        };
118
119        let opt_mark = if optional { "?" } else { "" };
120        out.push_str(&format!("  {name}{opt_mark}: {ts};\n"));
121    }
122
123    fn write_create_input(out: &mut String, model: &Model, schema: &Schema) {
124        out.push_str(&format!(
125            "\nexport interface {}CreateInput {{\n",
126            model.name()
127        ));
128        for (_, field) in &model.fields {
129            if field.is_relation() {
130                continue;
131            }
132            let attrs = field.extract_attributes();
133            if attrs.is_auto || attrs.is_updated_at {
134                continue;
135            }
136
137            let name = field.name().to_case(Case::Camel);
138            let ts_type = resolve_ts_type(field, schema);
139            let optional = field.modifier.is_optional() || attrs.default.is_some() || attrs.is_id;
140            let opt_mark = if optional { "?" } else { "" };
141            out.push_str(&format!("  {name}{opt_mark}: {ts_type};\n"));
142        }
143        out.push_str("}\n");
144    }
145
146    fn write_update_input(out: &mut String, model: &Model, schema: &Schema) {
147        out.push_str(&format!(
148            "\nexport interface {}UpdateInput {{\n",
149            model.name()
150        ));
151        for (_, field) in &model.fields {
152            if field.is_relation() {
153                continue;
154            }
155            let attrs = field.extract_attributes();
156            if attrs.is_auto || attrs.is_updated_at {
157                continue;
158            }
159
160            let name = field.name().to_case(Case::Camel);
161            let ts_type = resolve_ts_type(field, schema);
162            out.push_str(&format!("  {name}?: {ts_type};\n"));
163        }
164        out.push_str("}\n");
165    }
166}
167
168/// Resolve a field to its TypeScript type string.
169fn resolve_ts_type(field: &Field, _schema: &Schema) -> String {
170    let base = match &field.field_type {
171        FieldType::Scalar(s) => TypeMapper::ts_type(s).to_string(),
172        FieldType::Enum(name) => name.to_string(),
173        FieldType::Model(name) => name.to_string(),
174        FieldType::Composite(name) => name.to_string(),
175        FieldType::Unsupported(_) => "unknown".to_string(),
176    };
177
178    if field.modifier.is_list() {
179        format!("{base}[]")
180    } else if field.modifier.is_optional() {
181        format!("{base} | null")
182    } else {
183        base
184    }
185}
186
187fn write_jsdoc(out: &mut String, text: &str, indent: usize) {
188    let pad = " ".repeat(indent);
189    out.push_str(&format!("{pad}/**\n"));
190    for line in text.lines() {
191        out.push_str(&format!("{pad} * {line}\n"));
192    }
193    out.push_str(&format!("{pad} */\n"));
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use prax_schema::parse_schema;
200
201    #[test]
202    fn test_generate_simple_model() {
203        let schema = parse_schema(
204            r#"
205            model User {
206                id    Int    @id @auto
207                email String @unique
208                name  String?
209            }
210            "#,
211        )
212        .unwrap();
213
214        let output = InterfaceGenerator::generate(&schema);
215        assert!(output.contains("export interface User {"));
216        assert!(output.contains("id: number;"));
217        assert!(output.contains("email: string;"));
218        assert!(output.contains("name?: string | null;"));
219    }
220
221    #[test]
222    fn test_generate_enum() {
223        let schema = parse_schema(
224            r#"
225            enum Role {
226                User
227                Admin
228                Moderator
229            }
230            "#,
231        )
232        .unwrap();
233
234        let output = InterfaceGenerator::generate(&schema);
235        assert!(output.contains("export enum Role {"));
236        assert!(output.contains("User = 'User',"));
237        assert!(output.contains("Admin = 'Admin',"));
238        assert!(output.contains("Moderator = 'Moderator',"));
239    }
240
241    #[test]
242    fn test_generate_create_input() {
243        let schema = parse_schema(
244            r#"
245            model Post {
246                id      Int    @id @auto
247                title   String
248                content String?
249            }
250            "#,
251        )
252        .unwrap();
253
254        let output = InterfaceGenerator::generate(&schema);
255        assert!(output.contains("export interface PostCreateInput {"));
256        assert!(output.contains("title: string;"));
257        assert!(output.contains("content?: string | null;"));
258
259        let create_block = output
260            .split("export interface PostCreateInput {")
261            .nth(1)
262            .and_then(|s| s.split('}').next())
263            .unwrap_or("");
264        assert!(
265            !create_block.contains("id"),
266            "PostCreateInput should not contain auto-generated id field"
267        );
268    }
269
270    #[test]
271    fn test_generate_update_input_all_optional() {
272        let schema = parse_schema(
273            r#"
274            model User {
275                id   Int    @id @auto
276                name String
277            }
278            "#,
279        )
280        .unwrap();
281
282        let output = InterfaceGenerator::generate(&schema);
283        assert!(output.contains("export interface UserUpdateInput {"));
284        assert!(output.contains("name?: string;"));
285    }
286
287    #[test]
288    fn test_generate_relation_fields() {
289        let schema = parse_schema(
290            r#"
291            model User {
292                id    Int    @id @auto
293                posts Post[]
294            }
295            model Post {
296                id       Int  @id @auto
297                authorId Int
298                author   User @relation(fields: [authorId], references: [id])
299            }
300            "#,
301        )
302        .unwrap();
303
304        let output = InterfaceGenerator::generate(&schema);
305        assert!(output.contains("posts: Post[];"));
306        assert!(output.contains("author: User;"));
307    }
308
309    #[test]
310    fn test_generate_composite_type() {
311        let schema = parse_schema(
312            r#"
313            type Address {
314                street  String
315                city    String
316                country String
317            }
318            "#,
319        )
320        .unwrap();
321
322        let output = InterfaceGenerator::generate(&schema);
323        assert!(output.contains("export interface Address {"));
324        assert!(output.contains("street: string;"));
325    }
326
327    #[test]
328    fn test_enum_field_reference() {
329        let schema = parse_schema(
330            r#"
331            enum Status {
332                Active
333                Inactive
334            }
335            model User {
336                id     Int    @id @auto
337                status Status
338            }
339            "#,
340        )
341        .unwrap();
342
343        let output = InterfaceGenerator::generate(&schema);
344        assert!(output.contains("status: Status;"));
345    }
346
347    #[test]
348    fn test_list_field() {
349        let schema = parse_schema(
350            r#"
351            model User {
352                id   Int      @id @auto
353                tags String[]
354            }
355            "#,
356        )
357        .unwrap();
358
359        let output = InterfaceGenerator::generate(&schema);
360        assert!(output.contains("tags: string[];"));
361    }
362
363    #[test]
364    fn test_datetime_types() {
365        let schema = parse_schema(
366            r#"
367            model Event {
368                id        Int      @id @auto
369                startAt   DateTime
370                eventDate Date
371                eventTime Time
372            }
373            "#,
374        )
375        .unwrap();
376
377        let output = InterfaceGenerator::generate(&schema);
378        assert!(output.contains("startAt: Date;"));
379        assert!(output.contains("eventDate: string;"));
380        assert!(output.contains("eventTime: string;"));
381    }
382}