1use djogi::descriptor::{FieldDescriptor, ModelDescriptor, PkType};
33use djogi::relation::OnDelete;
34use serde::Serialize;
35use std::path::PathBuf;
36
37#[derive(Debug, Clone, Copy, clap::ValueEnum)]
41pub enum SchemaFormat {
42 Json,
43}
44
45#[derive(Debug, thiserror::Error)]
47pub enum SchemaError {
48 #[error("failed to write schema output to {path}: {source}")]
49 WriteFailed {
50 path: PathBuf,
51 #[source]
52 source: std::io::Error,
53 },
54 #[error("failed to serialize schema document: {0}")]
55 Serialize(#[from] serde_json::Error),
56 #[error("no models registered — link the binary against a crate that uses #[derive(Model)]")]
57 NoModelsRegistered,
58}
59
60#[derive(Debug, Serialize)]
65struct SchemaDocument {
66 schema_version: u32,
67 models: Vec<ModelEntry>,
68}
69
70#[derive(Debug, Serialize)]
71struct ModelEntry {
72 type_name: String,
73 table_name: String,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 app: Option<String>,
76 pk_type: String,
77 has_outbox: bool,
78 is_through: bool,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 rationale: Option<String>,
81 fields: Vec<FieldEntry>,
82 relations: Vec<RelationEntry>,
83}
84
85#[derive(Debug, Serialize)]
86struct FieldEntry {
87 name: String,
88 sql_type: String,
89 nullable: bool,
90 unique: bool,
91 indexed: bool,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 rationale: Option<String>,
94}
95
96#[derive(Debug, Serialize)]
97struct RelationEntry {
98 column: String,
99 target: String,
100 kind: &'static str,
101 on_delete: String,
102 nullable: bool,
103}
104
105pub fn run(
111 format: SchemaFormat,
112 models: &[&'static ModelDescriptor],
113 output: Option<PathBuf>,
114) -> Result<(), SchemaError> {
115 let document = collect_document(models);
116 if document.models.is_empty() {
117 return Err(SchemaError::NoModelsRegistered);
118 }
119 let mut bytes = match format {
120 SchemaFormat::Json => serde_json::to_vec_pretty(&document)?,
121 };
122 bytes.push(b'\n');
123
124 match output {
125 Some(path) => {
126 std::fs::write(&path, &bytes).map_err(|source| SchemaError::WriteFailed {
127 path: path.clone(),
128 source,
129 })?;
130 }
131 None => {
132 use std::io::Write;
133 let stdout = std::io::stdout();
134 let mut handle = stdout.lock();
135 handle
136 .write_all(&bytes)
137 .map_err(|source| SchemaError::WriteFailed {
138 path: PathBuf::from("<stdout>"),
139 source,
140 })?;
141 }
142 }
143 Ok(())
144}
145
146fn collect_document(models: &[&'static ModelDescriptor]) -> SchemaDocument {
147 let mut models: Vec<ModelEntry> = models.iter().map(|m| project_model(m)).collect();
148 models.sort_by(|a, b| {
149 let app_cmp = a.app.cmp(&b.app);
150 if app_cmp == std::cmp::Ordering::Equal {
151 a.type_name.cmp(&b.type_name)
152 } else {
153 app_cmp
154 }
155 });
156 SchemaDocument {
157 schema_version: 1,
158 models,
159 }
160}
161
162fn project_model(desc: &ModelDescriptor) -> ModelEntry {
163 let fields: Vec<FieldEntry> = desc.fields.iter().map(project_field).collect();
164
165 let mut relations: Vec<RelationEntry> =
166 desc.fields.iter().filter_map(project_relation).collect();
167 relations.sort_by(|a, b| a.column.cmp(&b.column));
168
169 ModelEntry {
170 type_name: desc.type_name.to_string(),
171 table_name: desc.table_name.to_string(),
172 app: desc.app.map(|s| s.to_string()),
173 pk_type: pk_type_label(desc.pk_type),
174 has_outbox: desc.has_outbox,
175 is_through: desc.is_through,
176 rationale: desc.rationale.map(|s| s.to_string()),
177 fields,
178 relations,
179 }
180}
181
182fn project_field(f: &FieldDescriptor) -> FieldEntry {
183 FieldEntry {
184 name: f.name.to_string(),
185 sql_type: f.sql_type.to_string(),
186 nullable: f.nullable,
187 unique: f.unique,
188 indexed: f.indexed,
189 rationale: f.rationale.map(|s| s.to_string()),
190 }
191}
192
193fn project_relation(f: &FieldDescriptor) -> Option<RelationEntry> {
194 let kind = f.relation_kind?;
195 let target = f.target_type_name?.to_string();
196 Some(RelationEntry {
197 column: f.name.to_string(),
198 target,
199 kind: relation_kind_label(kind),
200 on_delete: f
201 .on_delete
202 .map(|od| od.as_sql().to_string())
203 .unwrap_or_else(|| OnDelete::default().as_sql().to_string()),
204 nullable: f.nullable,
205 })
206}
207
208fn pk_type_label(pk: PkType) -> String {
212 match pk {
213 PkType::HeerId => "HeerId".to_string(),
214 PkType::RanjId => "RanjId".to_string(),
215 PkType::HeerIdDesc => "HeerIdDesc".to_string(),
216 PkType::RanjIdDesc => "RanjIdDesc".to_string(),
217 PkType::Serial => "Serial".to_string(),
218 PkType::None => "None".to_string(),
219 PkType::Composite(cols) => format!("Composite({})", cols.join(", ")),
220 PkType::Custom(c) => format!("Custom({})", c.type_name),
221 other => format!("{other:?}"),
225 }
226}
227
228fn relation_kind_label(kind: djogi::relation::RelationKind) -> &'static str {
229 match kind {
230 djogi::relation::RelationKind::ForeignKey => "ForeignKey",
231 djogi::relation::RelationKind::OneToOne => "OneToOne",
232 _ => "Unknown",
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn schema_document_serialises_known_shape() {
244 let doc = SchemaDocument {
245 schema_version: 1,
246 models: vec![ModelEntry {
247 type_name: "Vehicle".to_string(),
248 table_name: "vehicles".to_string(),
249 app: Some("main".to_string()),
250 pk_type: "HeerId".to_string(),
251 has_outbox: false,
252 is_through: false,
253 rationale: None,
254 fields: vec![FieldEntry {
255 name: "id".to_string(),
256 sql_type: "BIGINT".to_string(),
257 nullable: false,
258 unique: false,
259 indexed: false,
260 rationale: None,
261 }],
262 relations: vec![],
263 }],
264 };
265 let json = serde_json::to_string(&doc).expect("serialize");
266 assert!(json.starts_with(r#"{"schema_version":1,"models":["#));
267 assert!(json.contains(r#""type_name":"Vehicle""#));
268 assert!(json.contains(r#""table_name":"vehicles""#));
269 assert!(json.contains(r#""pk_type":"HeerId""#));
270 assert!(json.contains(r#""sql_type":"BIGINT""#));
271 }
272
273 #[test]
274 fn empty_inventory_yields_no_models() {
275 let doc = SchemaDocument {
276 schema_version: 1,
277 models: vec![],
278 };
279 let json = serde_json::to_string(&doc).expect("serialize");
280 assert_eq!(json, r#"{"schema_version":1,"models":[]}"#);
281 }
282
283 #[test]
284 fn omitted_fields_skip_when_none() {
285 let doc = SchemaDocument {
286 schema_version: 1,
287 models: vec![ModelEntry {
288 type_name: "Bare".to_string(),
289 table_name: "bares".to_string(),
290 app: None,
291 pk_type: "HeerId".to_string(),
292 has_outbox: false,
293 is_through: false,
294 rationale: None,
295 fields: vec![],
296 relations: vec![],
297 }],
298 };
299 let json = serde_json::to_string(&doc).expect("serialize");
300 assert!(
301 !json.contains(r#""app""#),
302 "app:None must be omitted: {json}"
303 );
304 assert!(
305 !json.contains(r#""rationale""#),
306 "rationale:None must be omitted: {json}"
307 );
308 }
309
310 #[test]
311 fn pk_type_label_renders_machine_friendly_strings() {
312 use djogi::descriptor::CustomPrimaryKeyKind;
313 assert_eq!(pk_type_label(PkType::HeerId), "HeerId");
314 assert_eq!(pk_type_label(PkType::RanjId), "RanjId");
315 assert_eq!(pk_type_label(PkType::HeerIdDesc), "HeerIdDesc");
316 assert_eq!(pk_type_label(PkType::RanjIdDesc), "RanjIdDesc");
317 assert_eq!(pk_type_label(PkType::Serial), "Serial");
318 assert_eq!(pk_type_label(PkType::None), "None");
319 assert_eq!(
320 pk_type_label(PkType::Composite(&["a", "b"])),
321 "Composite(a, b)"
322 );
323 assert_eq!(
324 pk_type_label(PkType::Custom(CustomPrimaryKeyKind {
325 type_name: "crate::ids::UserId",
326 sql_type: "UUID",
327 default_sql: "gen_random_uuid()",
328 })),
329 "Custom(crate::ids::UserId)"
330 );
331 }
332
333 #[test]
334 fn on_delete_set_null_renders_with_space() {
335 assert_eq!(OnDelete::SetNull.as_sql(), "SET NULL");
339 assert_eq!(OnDelete::SetDefault.as_sql(), "SET DEFAULT");
340 assert_eq!(OnDelete::DoNothing.as_sql(), "NO ACTION");
341 }
342}