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