entity_graph/
ir.rs

1//! Intermediate representation (IR) types mirrored from `@prometheus-ags/entity-graph-sdl`.
2//!
3//! These structs map 1-to-1 onto the JSON shape produced by `parseSdl()` so that
4//! the CLI can deserialize any `EntityGraphIR` JSON that the TS SDL parser emits.
5
6use serde::{Deserialize, Serialize};
7
8// ── Scalar types ─────────────────────────────────────────────────────────────
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub enum ScalarType {
13    String,
14    Number,
15    Integer,
16    Decimal,
17    Boolean,
18    Datetime,
19    Date,
20    Json,
21    Enum,
22    Uuid,
23}
24
25impl ScalarType {
26    /// Map the SDL scalar to a TypeScript type string.
27    pub fn to_ts_type(&self, values: Option<&[String]>) -> String {
28        match self {
29            ScalarType::String => "string".to_owned(),
30            ScalarType::Number | ScalarType::Decimal => "number".to_owned(),
31            ScalarType::Integer => "number".to_owned(),
32            ScalarType::Boolean => "boolean".to_owned(),
33            ScalarType::Datetime | ScalarType::Date => "string".to_owned(),
34            ScalarType::Json => "unknown".to_owned(),
35            ScalarType::Uuid => "string".to_owned(),
36            ScalarType::Enum => {
37                if let Some(vs) = values {
38                    if vs.is_empty() {
39                        "string".to_owned()
40                    } else {
41                        vs.iter()
42                            .map(|v| format!("\"{}\"", v))
43                            .collect::<Vec<_>>()
44                            .join(" | ")
45                    }
46                } else {
47                    "string".to_owned()
48                }
49            }
50        }
51    }
52}
53
54// ── Field ─────────────────────────────────────────────────────────────────────
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct IrField {
58    pub name: String,
59    #[serde(rename = "type")]
60    pub field_type: ScalarType,
61    #[serde(default)]
62    pub primary: bool,
63    #[serde(default)]
64    pub required: bool,
65    #[serde(default)]
66    pub unique: bool,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub default: Option<serde_json::Value>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub values: Option<Vec<String>>,
71    #[serde(default)]
72    pub auto: bool,
73}
74
75impl IrField {
76    /// TypeScript type including optionality marker.
77    pub fn ts_type_string(&self) -> String {
78        self.field_type.to_ts_type(self.values.as_deref())
79    }
80
81    /// Whether the field is optional in the TypeScript interface.
82    pub fn is_optional(&self) -> bool {
83        !self.required && !self.primary
84    }
85}
86
87// ── Relation ──────────────────────────────────────────────────────────────────
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub enum RelationKind {
92    BelongsTo,
93    HasMany,
94    ManyToMany,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct IrRelation {
99    pub name: String,
100    #[serde(rename = "type")]
101    pub kind: RelationKind,
102    pub target: String,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub foreign_key: Option<String>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub through: Option<String>,
107}
108
109// ── Entity ────────────────────────────────────────────────────────────────────
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct IrEntity {
113    pub name: String,
114    pub table: String,
115    pub primary_key: String,
116    pub fields: Vec<IrField>,
117    pub relations: Vec<IrRelation>,
118}
119
120// ── Config ────────────────────────────────────────────────────────────────────
121
122#[derive(Debug, Clone, Default, Serialize, Deserialize)]
123pub struct LocalFirstConfig {
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub engine: Option<String>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub sync: Option<String>,
128}
129
130#[derive(Debug, Clone, Default, Serialize, Deserialize)]
131pub struct AiConfig {
132    #[serde(default)]
133    pub mcp: bool,
134    #[serde(default)]
135    pub a2a: bool,
136}
137
138#[derive(Debug, Clone, Default, Serialize, Deserialize)]
139pub struct SdlConfig {
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub local_first: Option<LocalFirstConfig>,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub ai: Option<AiConfig>,
144}
145
146// ── Root IR ───────────────────────────────────────────────────────────────────
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct EntityGraphIR {
150    pub version: String,
151    pub entities: Vec<IrEntity>,
152    #[serde(default)]
153    pub config: SdlConfig,
154}
155
156// ── Tests ─────────────────────────────────────────────────────────────────────
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use serde_json::json;
162
163    fn sample_ir_json() -> serde_json::Value {
164        json!({
165            "version": "1.0",
166            "entities": [
167                {
168                    "name": "user",
169                    "table": "user",
170                    "primary_key": "id",
171                    "fields": [
172                        { "name": "id", "type": "uuid", "primary": true, "required": true, "unique": true, "auto": false },
173                        { "name": "name", "type": "string", "primary": false, "required": true, "unique": false, "auto": false },
174                        { "name": "role", "type": "enum", "primary": false, "required": false, "unique": false, "auto": false,
175                          "values": ["admin", "member"] }
176                    ],
177                    "relations": [
178                        { "name": "orders", "type": "hasMany", "target": "order", "foreign_key": "userId" }
179                    ]
180                },
181                {
182                    "name": "order",
183                    "table": "order",
184                    "primary_key": "id",
185                    "fields": [
186                        { "name": "id", "type": "uuid", "primary": true, "required": true, "unique": true, "auto": false },
187                        { "name": "userId", "type": "uuid", "primary": false, "required": true, "unique": false, "auto": false }
188                    ],
189                    "relations": [
190                        { "name": "user", "type": "belongsTo", "target": "user", "foreign_key": "userId" }
191                    ]
192                }
193            ],
194            "config": {}
195        })
196    }
197
198    #[test]
199    fn deserialize_full_ir() {
200        let ir: EntityGraphIR = serde_json::from_value(sample_ir_json()).unwrap();
201        assert_eq!(ir.version, "1.0");
202        assert_eq!(ir.entities.len(), 2);
203        let user = &ir.entities[0];
204        assert_eq!(user.name, "user");
205        assert_eq!(user.primary_key, "id");
206        assert_eq!(user.fields.len(), 3);
207        assert_eq!(user.relations.len(), 1);
208    }
209
210    #[test]
211    fn scalar_type_to_ts() {
212        assert_eq!(ScalarType::String.to_ts_type(None), "string");
213        assert_eq!(ScalarType::Integer.to_ts_type(None), "number");
214        assert_eq!(ScalarType::Boolean.to_ts_type(None), "boolean");
215        assert_eq!(ScalarType::Uuid.to_ts_type(None), "string");
216        let e = ScalarType::Enum.to_ts_type(Some(&["a".into(), "b".into()]));
217        assert_eq!(e, "\"a\" | \"b\"");
218    }
219
220    #[test]
221    fn field_optionality() {
222        let required_field = IrField {
223            name: "name".into(),
224            field_type: ScalarType::String,
225            primary: false,
226            required: true,
227            unique: false,
228            default: None,
229            values: None,
230            auto: false,
231        };
232        assert!(!required_field.is_optional());
233
234        let optional_field = IrField {
235            name: "bio".into(),
236            field_type: ScalarType::String,
237            primary: false,
238            required: false,
239            unique: false,
240            default: None,
241            values: None,
242            auto: false,
243        };
244        assert!(optional_field.is_optional());
245    }
246}