1use 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#[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
79fn 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
106pub 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 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 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 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 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#[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}