1use convert_case::{Case, Casing};
4use prax_schema::{CompositeType, Enum, Field, FieldType, Model, Schema, View};
5
6use crate::mapping::TypeMapper;
7
8pub struct InterfaceGenerator;
10
11impl InterfaceGenerator {
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
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
168fn 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}