Skip to main content

djogi_cli/
schema.rs

1//! `djogi schema --format json` — emit a deterministic JSON document
2//! covering every registered model's shape.
3//! Adopters and tooling consume the document for agent integration
4//! (LLMs, schema browsers), CI assertions on schema drift, and
5//! machine-readable handoffs to downstream codegen.
6//! # JSON shape (schema_version = 1)
7//! ```json
8//! {
9//!   "schema_version": 1,
10//!   "models": [
11//!     {
12//!       "type_name": "Vehicle",
13//!       "table_name": "vehicles",
14//!       "app": "main",
15//!       "pk_type": "HeerId",
16//!       "fields": [
17//!         { "name": "id", "sql_type": "BIGINT", "nullable": false, "unique": false, "indexed": false },
18//!         ...
19//!       ],
20//!       "relations": [...]
21//!     }
22//!   ]
23//! }
24//! ```
25//! # Determinism
26//! - `models` is sorted by `(app, type_name)`, both ascending.
27//! - Within each model, `fields` follows declaration order.
28//! - `relations` is sorted alphabetically by source-column name.
29//!   Two consecutive runs against the same compiled binary produce
30//!   byte-equal output, suitable for `diff` in CI.
31
32use djogi::descriptor::{FieldDescriptor, ModelDescriptor, PkType};
33use djogi::relation::OnDelete;
34use serde::Serialize;
35use std::path::PathBuf;
36
37/// `--format` value for `djogi schema`. v0.1.0 ships JSON only;
38/// `openapi` and `markdown` slots are reserved for a future phase
39/// and will land without reshaping the existing flag.
40#[derive(Debug, Clone, Copy, clap::ValueEnum)]
41pub enum SchemaFormat {
42    Json,
43}
44
45/// Errors surfaced by [`run`].
46#[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/// Top-level JSON document emitted by `djogi schema`.
61/// `schema_version: 1` lets adopters match on the version when
62/// parsing future evolution. Major bumps are coordinated breaks;
63/// minor additive fields land without touching the version.
64#[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
105/// Run `djogi schema` against the registered descriptor inventory.
106/// Writes to `output` if `Some`, otherwise to stdout. Returns
107/// [`SchemaError::NoModelsRegistered`] if the inventory is empty
108/// almost always operator error (the binary was linked without a
109/// crate that uses `#[derive(Model)]`).
110pub 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
208/// Stable per-variant label for `PkType`. Avoids `Debug` formatting so
209/// `Composite([...])` and `Custom(CustomPrimaryKeyKind { ... })` don't
210/// leak Rust-internal shapes to JSON consumers.
211fn 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        // PkType is #[non_exhaustive]; future variants surface their
222        // Debug name so adopters see something more useful than a
223        // generic sentinel.
224        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        // RelationKind is #[non_exhaustive]; future variants
233        // will surface as "Unknown" until added here.
234        _ => "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        // Regression: format!("{:?}", OnDelete::SetNull).to_uppercase()
336        // would have emitted "SETNULL". Routing through OnDelete::as_sql
337        // surfaces the proper DDL spelling.
338        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}