1use std::io;
14use std::path::Path;
15
16use sea_orm::{
17 ColumnTrait, Iden, Identity, Iterable, PrimaryKeyToColumn, RelationDef, RelationTrait, RelationType,
18 sea_query::{ColumnType, TableRef},
19};
20use serde::Serialize;
21
22use crate::resource::{AccessPolicy, MiryadResource};
23
24#[derive(Debug, Clone, Serialize)]
25pub struct FieldIr {
26 pub name: String,
27 pub r#type: &'static str,
30 pub format: Option<&'static str>,
31 pub nullable: bool,
32 pub is_primary_key: bool,
33 pub references: Option<String>,
38}
39
40#[derive(Debug, Clone, Serialize)]
41pub struct EntityIr {
42 pub resource_name: String,
43 pub fields: Vec<FieldIr>,
44 pub read_policy: AccessPolicy,
45 pub write_policy: AccessPolicy,
46 pub owner_column: Option<String>,
47 pub filter_column: Option<String>,
48 pub label_column: Option<String>,
49}
50
51fn openapi_type(column_type: &ColumnType) -> (&'static str, Option<&'static str>) {
56 use ColumnType::*;
57 match column_type {
58 Char(_)
59 | String(_)
60 | Text
61 | Custom(_)
62 | Interval(..)
63 | Bit(_)
64 | VarBit(_)
65 | Cidr
66 | Inet
67 | MacAddr
68 | LTree
69 | Enum { .. } => ("string", None),
70 Blob | Binary(_) | VarBinary(_) => ("string", Some("byte")),
71 TinyInteger | SmallInteger | Integer | TinyUnsigned | SmallUnsigned | Unsigned | Year => {
72 ("integer", Some("int32"))
73 }
74 BigInteger | BigUnsigned => ("integer", Some("int64")),
75 Float => ("number", Some("float")),
76 Double => ("number", Some("double")),
77 Decimal(_) | Money(_) => ("string", None),
78 DateTime | Timestamp | TimestampWithTimeZone => ("string", Some("date-time")),
79 Time => ("string", Some("time")),
80 Date => ("string", Some("date")),
81 Boolean => ("boolean", None),
82 Json | JsonBinary => ("object", None),
83 Uuid => ("string", Some("uuid")),
84 Array(_) | Vector(_) => ("array", None),
85 _ => ("string", None),
88 }
89}
90
91fn resolve_reference_table(def: &RelationDef, column_name: &str) -> Option<String> {
104 if def.rel_type != RelationType::HasOne || def.is_owner {
105 return None;
106 }
107 let Identity::Unary(from_col) = &def.from_col else {
108 return None;
110 };
111 if from_col.to_string() != column_name {
112 return None;
113 }
114 match &def.to_tbl {
115 TableRef::Table(table_name, _) => Some(table_name.1.to_string()),
116 _ => None,
117 }
118}
119
120pub fn resource_ir<E: MiryadResource>() -> EntityIr {
123 let pk_names: Vec<String> = E::PrimaryKey::iter()
126 .map(|pk| pk.into_column().to_string())
127 .collect();
128
129 let fields = E::Column::iter()
130 .map(|col| {
131 let def = col.def();
132 let (ty, format) = openapi_type(def.get_column_type());
133 let name = col.to_string();
134 let references = E::Relation::iter().find_map(|rel| resolve_reference_table(&rel.def(), &name));
135 FieldIr {
136 is_primary_key: pk_names.contains(&name),
137 name,
138 r#type: ty,
139 format,
140 nullable: def.is_null(),
141 references,
142 }
143 })
144 .collect();
145
146 EntityIr {
147 resource_name: E::resource_name().to_string(),
148 fields,
149 read_policy: E::read_policy(),
150 write_policy: E::write_policy(),
151 owner_column: E::owner_column().map(|c| c.to_string()),
152 filter_column: E::filter_column().map(|c| c.to_string()),
153 label_column: E::label_column().map(|c| c.to_string()),
154 }
155}
156
157#[derive(Debug, Default)]
162pub struct IrRegistry {
163 entities: Vec<EntityIr>,
164 table_names: Vec<(String, String)>,
167}
168
169impl IrRegistry {
170 pub fn new() -> Self {
171 Self::default()
172 }
173
174 pub fn register<E: MiryadResource>(&mut self) -> &mut Self {
175 self.table_names.push((
176 E::default().table_name().to_string(),
177 E::resource_name().to_string(),
178 ));
179 self.entities.push(resource_ir::<E>());
180 self
181 }
182
183 fn resolved_entities(&self) -> Vec<EntityIr> {
189 self.entities
190 .iter()
191 .cloned()
192 .map(|mut entity| {
193 for field in &mut entity.fields {
194 field.references = field.references.as_deref().and_then(|raw_table| {
195 self.table_names
196 .iter()
197 .find(|(table, _)| table == raw_table)
198 .map(|(_, resource_name)| resource_name.clone())
199 });
200 }
201 entity
202 })
203 .collect()
204 }
205
206 pub fn write_to_file(&self, path: impl AsRef<Path>) -> io::Result<()> {
207 let json = serde_json::to_string_pretty(&self.resolved_entities())?;
208 std::fs::write(path, json)
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 mod recipe {
217 use crate::resource::{AccessPolicy, MiryadResource};
218 use sea_orm::entity::prelude::*;
219
220 #[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
221 #[sea_orm(table_name = "recipes")]
222 pub struct Model {
223 #[sea_orm(primary_key)]
224 pub id: i32,
225 pub title: String,
226 pub owner_id: i32,
227 pub notes: Option<String>,
228 }
229
230 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
231 pub enum Relation {}
232
233 impl ActiveModelBehavior for ActiveModel {}
234
235 impl MiryadResource for Entity {
236 fn resource_name() -> &'static str {
237 "recipes"
238 }
239 fn read_policy() -> AccessPolicy {
240 AccessPolicy::Public
241 }
242 fn write_policy() -> AccessPolicy {
243 AccessPolicy::OwnerOnly
244 }
245 fn owner_column() -> Option<Column> {
246 Some(Column::OwnerId)
247 }
248 fn filter_column() -> Option<Column> {
249 None
250 }
251 fn label_column() -> Option<Column> {
252 Some(Column::Title)
253 }
254 }
255 }
256
257 mod ingredient {
258 use crate::resource::{AccessPolicy, MiryadResource};
259 use sea_orm::entity::prelude::*;
260
261 #[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
262 #[sea_orm(table_name = "ingredients")]
263 pub struct Model {
264 #[sea_orm(primary_key)]
265 pub id: i32,
266 pub name: String,
267 }
268
269 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
270 pub enum Relation {}
271
272 impl ActiveModelBehavior for ActiveModel {}
273
274 impl MiryadResource for Entity {
275 fn resource_name() -> &'static str {
276 "ingredients"
277 }
278 fn read_policy() -> AccessPolicy {
279 AccessPolicy::AdminOnly
280 }
281 fn write_policy() -> AccessPolicy {
282 AccessPolicy::AdminOnly
283 }
284 fn owner_column() -> Option<Column> {
285 None
286 }
287 }
288 }
289
290 mod tag {
291 use crate::resource::{AccessPolicy, MiryadResource};
292 use sea_orm::entity::prelude::*;
293
294 #[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
295 #[sea_orm(table_name = "tags")]
296 pub struct Model {
297 #[sea_orm(primary_key)]
298 pub id: i32,
299 }
300
301 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
302 pub enum Relation {}
303
304 impl ActiveModelBehavior for ActiveModel {}
305
306 impl MiryadResource for Entity {
307 fn resource_name() -> &'static str {
310 "recipe-tags"
311 }
312 fn read_policy() -> AccessPolicy {
313 AccessPolicy::Public
314 }
315 fn write_policy() -> AccessPolicy {
316 AccessPolicy::AdminOnly
317 }
318 fn owner_column() -> Option<Column> {
319 None
320 }
321 }
322 }
323
324 mod recipe_ingredient {
328 use crate::resource::{AccessPolicy, MiryadResource};
329 use sea_orm::entity::prelude::*;
330
331 #[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
332 #[sea_orm(table_name = "recipe_ingredients")]
333 pub struct Model {
334 #[sea_orm(primary_key)]
335 pub id: i32,
336 pub recipe_id: i32,
337 pub ingredient_id: i32,
338 pub tag_id: i32,
339 }
340
341 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
342 pub enum Relation {
343 #[sea_orm(
344 belongs_to = "super::recipe::Entity",
345 from = "Column::RecipeId",
346 to = "super::recipe::Column::Id"
347 )]
348 Recipe,
349 #[sea_orm(
350 belongs_to = "super::ingredient::Entity",
351 from = "Column::IngredientId",
352 to = "super::ingredient::Column::Id"
353 )]
354 Ingredient,
355 #[sea_orm(
356 belongs_to = "super::tag::Entity",
357 from = "Column::TagId",
358 to = "super::tag::Column::Id"
359 )]
360 Tag,
361 }
362
363 impl ActiveModelBehavior for ActiveModel {}
364
365 impl MiryadResource for Entity {
366 fn resource_name() -> &'static str {
367 "recipe-ingredients"
368 }
369 fn read_policy() -> AccessPolicy {
370 AccessPolicy::Public
371 }
372 fn write_policy() -> AccessPolicy {
373 AccessPolicy::AdminOnly
374 }
375 fn owner_column() -> Option<Column> {
376 None
377 }
378 }
379 }
380
381 #[test]
382 fn resource_ir_reflects_fields_types_and_policy() {
383 let ir = resource_ir::<recipe::Entity>();
384
385 assert_eq!(ir.resource_name, "recipes");
386 assert_eq!(ir.read_policy, AccessPolicy::Public);
387 assert_eq!(ir.write_policy, AccessPolicy::OwnerOnly);
388 assert_eq!(ir.owner_column.as_deref(), Some("owner_id"));
389 assert_eq!(ir.label_column.as_deref(), Some("title"));
390 assert_eq!(ir.filter_column, None);
391
392 let id = ir.fields.iter().find(|f| f.name == "id").expect("id field");
393 assert_eq!(id.r#type, "integer");
394 assert!(id.is_primary_key);
395 assert!(!id.nullable);
396
397 let notes = ir.fields.iter().find(|f| f.name == "notes").expect("notes field");
398 assert_eq!(notes.r#type, "string");
399 assert!(notes.nullable);
400 }
401
402 #[test]
403 fn entity_without_label_column_override_defaults_to_none() {
404 let ir = resource_ir::<ingredient::Entity>();
405 assert_eq!(ir.label_column, None);
406 }
407
408 #[test]
409 fn write_to_file_produces_valid_json_array() {
410 let dir = std::env::temp_dir().join(format!("miryad-ir-test-{}", std::process::id()));
411 std::fs::create_dir_all(&dir).expect("create tmp dir");
412 let path = dir.join("ir.json");
413
414 let mut registry = IrRegistry::new();
415 registry.register::<recipe::Entity>();
416 registry.write_to_file(&path).expect("writes file");
417
418 let content = std::fs::read_to_string(&path).expect("reads file");
419 let parsed: Vec<serde_json::Value> = serde_json::from_str(&content).expect("valid json");
420 assert_eq!(parsed.len(), 1);
421 assert_eq!(parsed[0]["resource_name"], "recipes");
422
423 std::fs::remove_dir_all(&dir).ok();
424 }
425
426 #[test]
427 fn resource_ir_reports_raw_table_name_for_belongs_to_relations() {
428 let ir = resource_ir::<recipe_ingredient::Entity>();
431
432 let recipe_id = ir
433 .fields
434 .iter()
435 .find(|f| f.name == "recipe_id")
436 .expect("recipe_id field");
437 assert_eq!(recipe_id.references.as_deref(), Some("recipes"));
438
439 let ingredient_id = ir
440 .fields
441 .iter()
442 .find(|f| f.name == "ingredient_id")
443 .expect("ingredient_id field");
444 assert_eq!(ingredient_id.references.as_deref(), Some("ingredients"));
445
446 let tag_id = ir
450 .fields
451 .iter()
452 .find(|f| f.name == "tag_id")
453 .expect("tag_id field");
454 assert_eq!(tag_id.references.as_deref(), Some("tags"));
455
456 let id = ir.fields.iter().find(|f| f.name == "id").expect("id field");
457 assert_eq!(id.references, None);
458 }
459
460 #[test]
461 fn ir_registry_resolves_references_to_resource_name() {
462 let mut registry = IrRegistry::new();
463 registry.register::<recipe::Entity>();
464 registry.register::<ingredient::Entity>();
465 registry.register::<tag::Entity>();
466 registry.register::<recipe_ingredient::Entity>();
467
468 let resolved = registry.resolved_entities();
469 let recipe_ingredient_ir = resolved
470 .iter()
471 .find(|e| e.resource_name == "recipe-ingredients")
472 .expect("recipe_ingredient registered");
473
474 let recipe_id = recipe_ingredient_ir
475 .fields
476 .iter()
477 .find(|f| f.name == "recipe_id")
478 .expect("recipe_id field");
479 assert_eq!(recipe_id.references.as_deref(), Some("recipes"));
480
481 let tag_id = recipe_ingredient_ir
484 .fields
485 .iter()
486 .find(|f| f.name == "tag_id")
487 .expect("tag_id field");
488 assert_eq!(tag_id.references.as_deref(), Some("recipe-tags"));
489 }
490
491 #[test]
492 fn ir_registry_leaves_reference_unresolved_when_target_entity_not_registered() {
493 let mut registry = IrRegistry::new();
494 registry.register::<recipe_ingredient::Entity>();
495 let resolved = registry.resolved_entities();
498 let recipe_ingredient_ir = &resolved[0];
499
500 for field in ["recipe_id", "ingredient_id", "tag_id"] {
501 let field_ir = recipe_ingredient_ir
502 .fields
503 .iter()
504 .find(|f| f.name == field)
505 .unwrap_or_else(|| panic!("{field} field"));
506 assert_eq!(field_ir.references, None, "{field} should stay unresolved");
507 }
508 }
509
510 fn relation_def(
511 rel_type: RelationType,
512 is_owner: bool,
513 from_col: Identity,
514 to_tbl: &'static str,
515 ) -> RelationDef {
516 use sea_orm::sea_query::{ConditionType, IntoIden, IntoTableRef};
517
518 RelationDef {
519 rel_type,
520 from_tbl: "from".into_table_ref(),
521 to_tbl: to_tbl.into_table_ref(),
522 from_col,
523 to_col: Identity::Unary("id".into_iden()),
524 is_owner,
525 skip_fk: false,
526 on_delete: None,
527 on_update: None,
528 on_condition: None,
529 fk_name: None,
530 condition_type: ConditionType::All,
531 }
532 }
533
534 #[test]
535 fn resolve_reference_table_matches_belongs_to_on_the_right_column() {
536 use sea_orm::sea_query::IntoIden;
537
538 let def = relation_def(
539 RelationType::HasOne,
540 false,
541 Identity::Unary("recipe_id".into_iden()),
542 "recipes",
543 );
544 assert_eq!(
545 resolve_reference_table(&def, "recipe_id").as_deref(),
546 Some("recipes")
547 );
548 assert_eq!(resolve_reference_table(&def, "other_id"), None);
550 }
551
552 #[test]
553 fn resolve_reference_table_ignores_reversed_has_one_has_many() {
554 use sea_orm::sea_query::IntoIden;
555
556 let has_one_reversed = relation_def(
559 RelationType::HasOne,
560 true,
561 Identity::Unary("id".into_iden()),
562 "recipes",
563 );
564 assert_eq!(resolve_reference_table(&has_one_reversed, "id"), None);
565
566 let has_many = relation_def(
567 RelationType::HasMany,
568 false,
569 Identity::Unary("recipe_id".into_iden()),
570 "recipes",
571 );
572 assert_eq!(resolve_reference_table(&has_many, "recipe_id"), None);
573 }
574
575 #[test]
576 fn resolve_reference_table_ignores_composite_foreign_keys() {
577 use sea_orm::sea_query::IntoIden;
578
579 let composite = relation_def(
580 RelationType::HasOne,
581 false,
582 Identity::Binary("a_id".into_iden(), "b_id".into_iden()),
583 "recipes",
584 );
585 assert_eq!(resolve_reference_table(&composite, "a_id"), None);
586 }
587}