entity_graph/
sdl_parser.rs

1//! Parse the human-editable SDL source format (written by `init`) into an
2//! [`EntityGraphIR`]. This mirrors the behaviour of `parseSdl()` in
3//! `@prometheus-ags/entity-graph-sdl`.
4//!
5//! The source format has entities as a JSON object (keyed by name), whereas the
6//! IR uses a flat array. The parser also validates field types, enum values, and
7//! relation cross-references, matching the TypeScript reference implementation.
8
9use std::collections::{HashMap, HashSet};
10
11use anyhow::{anyhow, bail, Context, Result};
12use serde::Deserialize;
13use serde_json::Value;
14
15use crate::ir::{
16    AiConfig, EntityGraphIR, IrEntity, IrField, IrRelation, LocalFirstConfig, RelationKind,
17    ScalarType, SdlConfig,
18};
19
20// ── Source-format deserialization types ───────────────────────────────────────
21
22#[derive(Debug, Deserialize)]
23struct SdlDocument {
24    version: Option<String>,
25    entities: HashMap<String, SdlEntityDef>,
26    config: Option<SdlConfigRaw>,
27}
28
29#[derive(Debug, Deserialize)]
30struct SdlEntityDef {
31    fields: HashMap<String, SdlFieldDef>,
32    relations: Option<HashMap<String, SdlRelationDef>>,
33    table: Option<String>,
34}
35
36#[derive(Debug, Deserialize)]
37struct SdlFieldDef {
38    #[serde(rename = "type")]
39    field_type: String,
40    primary: Option<bool>,
41    required: Option<bool>,
42    unique: Option<bool>,
43    default: Option<Value>,
44    values: Option<Vec<String>>,
45    auto: Option<bool>,
46}
47
48#[derive(Debug, Deserialize)]
49struct SdlRelationDef {
50    #[serde(rename = "type")]
51    kind: String,
52    target: String,
53    #[serde(rename = "foreignKey")]
54    foreign_key: Option<String>,
55    through: Option<String>,
56}
57
58#[derive(Debug, Deserialize)]
59struct SdlConfigRaw {
60    #[serde(rename = "localFirst")]
61    local_first: Option<LocalFirstRaw>,
62    ai: Option<AiRaw>,
63}
64
65#[derive(Debug, Deserialize)]
66struct LocalFirstRaw {
67    engine: Option<String>,
68    sync: Option<String>,
69}
70
71#[derive(Debug, Deserialize)]
72struct AiRaw {
73    #[serde(default)]
74    mcp: bool,
75    #[serde(default)]
76    a2a: bool,
77}
78
79// ── Scalar / relation type parsing ────────────────────────────────────────────
80
81fn parse_scalar(s: &str, path: &str) -> Result<ScalarType> {
82    match s {
83        "string" => Ok(ScalarType::String),
84        "number" => Ok(ScalarType::Number),
85        "integer" => Ok(ScalarType::Integer),
86        "decimal" => Ok(ScalarType::Decimal),
87        "boolean" => Ok(ScalarType::Boolean),
88        "datetime" => Ok(ScalarType::Datetime),
89        "date" => Ok(ScalarType::Date),
90        "json" => Ok(ScalarType::Json),
91        "enum" => Ok(ScalarType::Enum),
92        "uuid" => Ok(ScalarType::Uuid),
93        other => bail!("unknown field type \"{}\" at {}", other, path),
94    }
95}
96
97fn parse_relation_kind(s: &str, path: &str) -> Result<RelationKind> {
98    match s {
99        "belongsTo" => Ok(RelationKind::BelongsTo),
100        "hasMany" => Ok(RelationKind::HasMany),
101        "manyToMany" => Ok(RelationKind::ManyToMany),
102        other => bail!("unknown relation type \"{}\" at {}", other, path),
103    }
104}
105
106// ── Public entry point ────────────────────────────────────────────────────────
107
108/// Parse the human-editable SDL source JSON into an [`EntityGraphIR`].
109pub fn parse_sdl_source(json: &str) -> Result<EntityGraphIR> {
110    let doc: SdlDocument =
111        serde_json::from_str(json).context("invalid SDL JSON (source format)")?;
112    let version = doc.version.unwrap_or_else(|| "1.0".into());
113
114    let mut entities: Vec<IrEntity> = Vec::with_capacity(doc.entities.len());
115
116    for (entity_name, entity_def) in &doc.entities {
117        let entity_path = format!("$.entities.{}", entity_name);
118
119        // ── Fields ────────────────────────────────────────────────────────────
120        let mut fields: Vec<IrField> = Vec::with_capacity(entity_def.fields.len());
121        let mut primary_key: Option<String> = None;
122
123        for (field_name, field_def) in &entity_def.fields {
124            let field_path = format!("{}.fields.{}", entity_path, field_name);
125            let scalar = parse_scalar(&field_def.field_type, &field_path)?;
126
127            if matches!(scalar, ScalarType::Enum) {
128                match &field_def.values {
129                    None => bail!("enum field requires non-empty `values` at {}", field_path),
130                    Some(v) if v.is_empty() => {
131                        bail!("enum field requires non-empty `values` at {}", field_path)
132                    }
133                    _ => {}
134                }
135            }
136
137            let is_primary = field_def.primary.unwrap_or(false);
138            if is_primary {
139                if primary_key.is_some() {
140                    bail!("multiple primary keys not supported at {}", field_path);
141                }
142                primary_key = Some(field_name.clone());
143            }
144
145            fields.push(IrField {
146                name: field_name.clone(),
147                field_type: scalar,
148                primary: is_primary,
149                required: field_def.required.unwrap_or(false),
150                unique: field_def.unique.unwrap_or(false),
151                default: field_def.default.clone(),
152                values: field_def.values.clone(),
153                auto: field_def.auto.unwrap_or(false),
154            });
155        }
156
157        let pk = primary_key.ok_or_else(|| {
158            anyhow!(
159                "entity needs exactly one `primary: true` field at {}",
160                entity_path
161            )
162        })?;
163
164        // ── Relations ─────────────────────────────────────────────────────────
165        let mut relations: Vec<IrRelation> = Vec::new();
166        for (rel_name, rel_def) in entity_def.relations.iter().flatten() {
167            let rel_path = format!("{}.relations.{}", entity_path, rel_name);
168            let kind = parse_relation_kind(&rel_def.kind, &rel_path)?;
169            relations.push(IrRelation {
170                name: rel_name.clone(),
171                kind,
172                target: rel_def.target.clone(),
173                foreign_key: rel_def.foreign_key.clone(),
174                through: rel_def.through.clone(),
175            });
176        }
177
178        entities.push(IrEntity {
179            name: entity_name.clone(),
180            table: entity_def.table.clone().unwrap_or_else(|| entity_name.clone()),
181            primary_key: pk,
182            fields,
183            relations,
184        });
185    }
186
187    // Cross-reference relation targets.
188    let names: HashSet<&str> = entities.iter().map(|e| e.name.as_str()).collect();
189    for entity in &entities {
190        for rel in &entity.relations {
191            if !names.contains(rel.target.as_str()) {
192                bail!(
193                    "relation target \"{}\" is not a defined entity at $.entities.{}.relations.{}",
194                    rel.target,
195                    entity.name,
196                    rel.name,
197                );
198            }
199        }
200    }
201
202    // ── Config ────────────────────────────────────────────────────────────────
203    let config = doc.config.map(|c| SdlConfig {
204        local_first: c.local_first.map(|lf| LocalFirstConfig {
205            engine: lf.engine,
206            sync: lf.sync,
207        }),
208        ai: c.ai.map(|a| AiConfig { mcp: a.mcp, a2a: a.a2a }),
209    }).unwrap_or_default();
210
211    Ok(EntityGraphIR { version, entities, config })
212}
213
214// ── Tests ─────────────────────────────────────────────────────────────────────
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    fn starter_schema_json() -> &'static str {
221        r#"{
222          "version": "1.0",
223          "entities": {
224            "user": {
225              "fields": {
226                "id":        { "type": "uuid",   "primary": true, "required": true },
227                "email":     { "type": "string", "required": true, "unique": true },
228                "role":      { "type": "enum",   "values": ["admin","member"] },
229                "createdAt": { "type": "datetime", "auto": true }
230              },
231              "relations": {
232                "posts": { "type": "hasMany", "target": "post", "foreignKey": "userId" }
233              }
234            },
235            "post": {
236              "fields": {
237                "id":     { "type": "uuid",   "primary": true, "required": true },
238                "title":  { "type": "string", "required": true },
239                "userId": { "type": "uuid",   "required": true }
240              },
241              "relations": {
242                "author": { "type": "belongsTo", "target": "user", "foreignKey": "userId" }
243              }
244            }
245          },
246          "config": {
247            "localFirst": { "engine": "pglite", "sync": "electric" },
248            "ai": { "mcp": true, "a2a": false }
249          }
250        }"#
251    }
252
253    #[test]
254    fn parses_starter_schema() {
255        let ir = parse_sdl_source(starter_schema_json()).unwrap();
256        assert_eq!(ir.version, "1.0");
257        assert_eq!(ir.entities.len(), 2);
258    }
259
260    #[test]
261    fn entity_primary_keys_resolved() {
262        let ir = parse_sdl_source(starter_schema_json()).unwrap();
263        for entity in &ir.entities {
264            assert_eq!(entity.primary_key, "id", "entity {} pk", entity.name);
265        }
266    }
267
268    #[test]
269    fn config_parsed() {
270        let ir = parse_sdl_source(starter_schema_json()).unwrap();
271        let lf = ir.config.local_first.unwrap();
272        assert_eq!(lf.engine.unwrap(), "pglite");
273        assert_eq!(lf.sync.unwrap(), "electric");
274        let ai = ir.config.ai.unwrap();
275        assert!(ai.mcp);
276        assert!(!ai.a2a);
277    }
278
279    #[test]
280    fn rejects_enum_without_values() {
281        let bad = r#"{
282          "version": "1.0",
283          "entities": {
284            "thing": {
285              "fields": {
286                "id":   { "type": "uuid", "primary": true },
287                "kind": { "type": "enum" }
288              }
289            }
290          }
291        }"#;
292        let err = parse_sdl_source(bad).unwrap_err().to_string();
293        assert!(err.contains("values"), "expected values error: {err}");
294    }
295
296    #[test]
297    fn rejects_unknown_relation_target() {
298        let bad = r#"{
299          "version": "1.0",
300          "entities": {
301            "user": {
302              "fields": { "id": { "type": "uuid", "primary": true } },
303              "relations": {
304                "ghosts": { "type": "hasMany", "target": "ghost" }
305              }
306            }
307          }
308        }"#;
309        let err = parse_sdl_source(bad).unwrap_err().to_string();
310        assert!(err.contains("ghost"), "expected cross-ref error: {err}");
311    }
312
313    #[test]
314    fn rejects_missing_primary_key() {
315        let bad = r#"{
316          "version": "1.0",
317          "entities": {
318            "thing": {
319              "fields": {
320                "name": { "type": "string", "required": true }
321              }
322            }
323          }
324        }"#;
325        let err = parse_sdl_source(bad).unwrap_err().to_string();
326        assert!(err.contains("primary"), "expected primary-key error: {err}");
327    }
328
329    #[test]
330    fn rejects_unknown_scalar_type() {
331        let bad = r#"{
332          "version": "1.0",
333          "entities": {
334            "thing": {
335              "fields": {
336                "id":   { "type": "uuid",    "primary": true },
337                "data": { "type": "bigint" }
338              }
339            }
340          }
341        }"#;
342        let err = parse_sdl_source(bad).unwrap_err().to_string();
343        assert!(err.contains("bigint"), "expected unknown-type error: {err}");
344    }
345}