1use std::path::PathBuf;
5
6use anyhow::{Context, Result};
7use convert_case::{Case, Casing};
8use serde::Serialize;
9use tera::{Context as TeraCtx, Tera};
10
11use crate::{
12 ir::{EntityGraphIR, IrEntity, RelationKind},
13 sdl_parser,
14 templates,
15};
16
17#[derive(Debug, Clone, clap::ValueEnum)]
21pub enum Target {
22 React,
24}
25
26#[derive(Debug, clap::Args)]
28pub struct GenerateArgs {
29 #[arg(short, long, value_enum, default_value = "react")]
31 pub target: Target,
32
33 #[arg(short, long, default_value = "schema.json")]
35 pub schema: PathBuf,
36
37 #[arg(short, long, default_value = "generated")]
39 pub out: PathBuf,
40}
41
42#[derive(Debug, Serialize)]
46struct FieldCtx {
47 name: String,
48 ts_type: String,
49 optional: bool,
50 primary: bool,
51 auto: bool,
52}
53
54#[derive(Debug, Serialize)]
56struct RelationCtx {
57 name: String,
58 kind: String,
59 target_pascal: String,
60 foreign_key: Option<String>,
61}
62
63#[derive(Debug, Serialize)]
65struct EntityCtx {
66 pascal_name: String,
67 snake_name: String,
68 table: String,
69 primary_key: String,
70 fields: Vec<FieldCtx>,
71 relations: Vec<RelationCtx>,
72 has_auto_fields: bool,
73 auto_field_names: String,
75}
76
77fn build_entity_ctx(entity: &IrEntity) -> EntityCtx {
80 let fields: Vec<FieldCtx> = entity
81 .fields
82 .iter()
83 .map(|f| FieldCtx {
84 name: f.name.clone(),
85 ts_type: f.ts_type_string(),
86 optional: f.is_optional(),
87 primary: f.primary,
88 auto: f.auto,
89 })
90 .collect();
91
92 let auto_fields: Vec<&str> = entity
93 .fields
94 .iter()
95 .filter(|f| f.auto)
96 .map(|f| f.name.as_str())
97 .collect();
98 let has_auto_fields = !auto_fields.is_empty();
99 let auto_field_names = auto_fields
100 .iter()
101 .map(|n| format!("'{}'", n))
102 .collect::<Vec<_>>()
103 .join(" | ");
104
105 let relations: Vec<RelationCtx> = entity
106 .relations
107 .iter()
108 .map(|r| RelationCtx {
109 name: r.name.clone(),
110 kind: match r.kind {
111 RelationKind::BelongsTo => "belongsTo",
112 RelationKind::HasMany => "hasMany",
113 RelationKind::ManyToMany => "manyToMany",
114 }
115 .to_owned(),
116 target_pascal: r.target.to_case(Case::Pascal),
117 foreign_key: r.foreign_key.clone(),
118 })
119 .collect();
120
121 EntityCtx {
122 pascal_name: entity.name.to_case(Case::Pascal),
123 snake_name: entity.name.to_case(Case::Snake),
124 table: entity.table.clone(),
125 primary_key: entity.primary_key.clone(),
126 fields,
127 relations,
128 has_auto_fields,
129 auto_field_names,
130 }
131}
132
133fn build_tera_ctx(ir: &EntityGraphIR, schema_path: &str) -> TeraCtx {
135 let entities: Vec<EntityCtx> = ir.entities.iter().map(build_entity_ctx).collect();
136 let mut ctx = TeraCtx::new();
137 ctx.insert("entities", &entities);
138 ctx.insert("schema_path", schema_path);
139 ctx.insert("cli_version", env!("CARGO_PKG_VERSION"));
140 ctx
141}
142
143fn render(template_name: &str, template_src: &str, ctx: &TeraCtx) -> Result<String> {
145 let mut tera = Tera::default();
146 tera.add_raw_template(template_name, template_src)
147 .with_context(|| format!("failed to parse template '{template_name}'"))?;
148 tera.render(template_name, ctx)
149 .with_context(|| format!("failed to render template '{template_name}'"))
150}
151
152pub fn run(args: &GenerateArgs) -> Result<()> {
156 let schema_str = std::fs::read_to_string(&args.schema)
161 .with_context(|| format!("cannot read schema from {}", args.schema.display()))?;
162
163 let ir: EntityGraphIR = {
164 let ir_attempt: Result<EntityGraphIR> = serde_json::from_str(&schema_str)
166 .with_context(|| format!("IR parse attempt for {}", args.schema.display()));
167 match ir_attempt {
168 Ok(ir) if !ir.entities.is_empty() || ir.version == "1.0" => ir,
169 _ => {
170 sdl_parser::parse_sdl_source(&schema_str)
172 .with_context(|| format!("invalid SDL JSON in {}", args.schema.display()))?
173 }
174 }
175 };
176
177 let schema_path = args.schema.to_string_lossy().into_owned();
178
179 if !args.out.exists() {
181 std::fs::create_dir_all(&args.out)
182 .with_context(|| format!("cannot create output dir {}", args.out.display()))?;
183 }
184
185 let ctx = build_tera_ctx(&ir, &schema_path);
186
187 match args.target {
188 Target::React => generate_react(&args.out, &ctx)?,
189 }
190
191 Ok(())
192}
193
194fn generate_react(out: &std::path::Path, ctx: &TeraCtx) -> Result<()> {
195 let types_src = render("entity-types.ts.tera", templates::ENTITY_TYPES_TS, ctx)?;
197 let types_path = out.join("entity-types.ts");
198 std::fs::write(&types_path, &types_src)
199 .with_context(|| format!("cannot write {}", types_path.display()))?;
200 println!("Wrote {}", types_path.display());
201
202 let reg_src = render(
204 "register-transports.ts.tera",
205 templates::REGISTER_TRANSPORTS_TS,
206 ctx,
207 )?;
208 let reg_path = out.join("register-transports.ts");
209 std::fs::write(®_path, ®_src)
210 .with_context(|| format!("cannot write {}", reg_path.display()))?;
211 println!("Wrote {}", reg_path.display());
212
213 let index_src = render("index.ts.tera", templates::INDEX_TS, ctx)?;
215 let index_path = out.join("index.ts");
216 std::fs::write(&index_path, &index_src)
217 .with_context(|| format!("cannot write {}", index_path.display()))?;
218 println!("Wrote {}", index_path.display());
219
220 println!();
221 println!("Done. {} entity/entities generated.", {
222 let dummy: serde_json::Value =
224 serde_json::to_value(ctx.get("entities").unwrap_or(&tera::Value::Null))
225 .unwrap_or_default();
226 dummy.as_array().map(|a| a.len()).unwrap_or(0)
227 });
228
229 Ok(())
230}
231
232#[cfg(test)]
235mod tests {
236 use super::*;
237 use crate::ir::{IrField, IrRelation, ScalarType};
238 use tempfile::tempdir;
239
240 fn sample_ir() -> EntityGraphIR {
241 EntityGraphIR {
242 version: "1.0".into(),
243 entities: vec![
244 crate::ir::IrEntity {
245 name: "user".into(),
246 table: "user".into(),
247 primary_key: "id".into(),
248 fields: vec![
249 IrField {
250 name: "id".into(),
251 field_type: ScalarType::Uuid,
252 primary: true,
253 required: true,
254 unique: true,
255 default: None,
256 values: None,
257 auto: false,
258 },
259 IrField {
260 name: "email".into(),
261 field_type: ScalarType::String,
262 primary: false,
263 required: true,
264 unique: true,
265 default: None,
266 values: None,
267 auto: false,
268 },
269 IrField {
270 name: "createdAt".into(),
271 field_type: ScalarType::Datetime,
272 primary: false,
273 required: false,
274 unique: false,
275 default: None,
276 values: None,
277 auto: true,
278 },
279 ],
280 relations: vec![IrRelation {
281 name: "posts".into(),
282 kind: RelationKind::HasMany,
283 target: "post".into(),
284 foreign_key: Some("userId".into()),
285 through: None,
286 }],
287 },
288 ],
289 config: Default::default(),
290 }
291 }
292
293 #[test]
294 fn build_entity_ctx_pascal_name() {
295 let ir = sample_ir();
296 let ctx = build_entity_ctx(&ir.entities[0]);
297 assert_eq!(ctx.pascal_name, "User");
298 }
299
300 #[test]
301 fn build_entity_ctx_auto_fields() {
302 let ir = sample_ir();
303 let ctx = build_entity_ctx(&ir.entities[0]);
304 assert!(ctx.has_auto_fields);
305 assert_eq!(ctx.auto_field_names, "'createdAt'");
306 }
307
308 #[test]
309 fn render_entity_types_contains_interface() {
310 let ir = sample_ir();
311 let ctx = build_tera_ctx(&ir, "schema.json");
312 let out = render("t.tera", templates::ENTITY_TYPES_TS, &ctx).unwrap();
313 assert!(out.contains("export interface User"), "expected interface: {out}");
314 assert!(out.contains("id: string"), "expected id field: {out}");
315 assert!(out.contains("email: string"), "expected email field: {out}");
316 assert!(out.contains("CreateUserInput"), "expected CreateUserInput: {out}");
318 }
319
320 #[test]
321 fn render_register_transports_contains_register_call() {
322 let ir = sample_ir();
323 let ctx = build_tera_ctx(&ir, "schema.json");
324 let out = render("t.tera", templates::REGISTER_TRANSPORTS_TS, &ctx).unwrap();
325 assert!(
326 out.contains("registerEntityTransport"),
327 "expected register call: {out}"
328 );
329 assert!(out.contains("User"), "expected User type: {out}");
330 }
331
332 #[test]
333 fn generate_writes_files_to_outdir() {
334 let tmp = tempdir().unwrap();
335 let schema_path = tmp.path().join("schema.json");
336
337 let ir = sample_ir();
339 std::fs::write(&schema_path, serde_json::to_string(&ir).unwrap()).unwrap();
340
341 let out_dir = tmp.path().join("generated");
342 let args = GenerateArgs {
343 target: Target::React,
344 schema: schema_path.clone(),
345 out: out_dir.clone(),
346 };
347 run(&args).unwrap();
348
349 assert!(out_dir.join("entity-types.ts").exists(), "entity-types.ts");
350 assert!(
351 out_dir.join("register-transports.ts").exists(),
352 "register-transports.ts"
353 );
354 assert!(out_dir.join("index.ts").exists(), "index.ts");
355
356 let types = std::fs::read_to_string(out_dir.join("entity-types.ts")).unwrap();
357 assert!(types.contains("export interface User"));
358 }
359
360 #[test]
361 fn generate_errors_on_missing_schema() {
362 let tmp = tempdir().unwrap();
363 let args = GenerateArgs {
364 target: Target::React,
365 schema: tmp.path().join("missing.json"),
366 out: tmp.path().join("out"),
367 };
368 assert!(run(&args).is_err());
369 }
370}