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