Skip to main content

miryad_core/
ir.rs

1//! Représentation intermédiaire (IR) par entité — feature 8. Dérivée des métadonnées SeaORM déjà
2//! obligatoires pour toute `MiryadResource` (aucune annotation supplémentaire à ajouter par
3//! l'app), destinée au générateur frontend TypeScript du template `miryad`. Séparée d'`openapi.json`
4//! (feature 4b) : deux publics différents, deux artefacts — cf. `docs/architecture.md`.
5//!
6//! `FieldIr::references` (relations, #19) est résolu en deux temps : `resource_ir::<E>()` est pure
7//! et ne connaît que le nom de table SQL physique cible (via `E::Relation`, même déclaration que
8//! GraphQL — cf. `docs/architecture.md`, section "API GraphQL") ; `IrRegistry` résout ce nom de
9//! table en `resource_name` une fois toutes les entités enregistrées (`resolved_entities`, appelée
10//! par `write_to_file`) — une entité seule ne peut pas savoir quel `resource_name` porte une table
11//! qu'elle référence.
12
13use 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    /// Type primitif OpenAPI ("string" | "integer" | "number" | "boolean" | "object" | "array") —
28    /// vocabulaire repris d'OpenAPI, pas un enum maison, déjà compris par l'outillage JS/TS.
29    pub r#type: &'static str,
30    pub format: Option<&'static str>,
31    pub nullable: bool,
32    pub is_primary_key: bool,
33    /// `resource_name` de l'entité référencée par une relation `belongs_to` sur cette colonne
34    /// (`E::Relation`) — `None` si la colonne n'est pas une FK scalaire simple (pas de relation
35    /// correspondante, FK composite, ou entité cible non enregistrée dans le même `IrRegistry`).
36    /// Résolu par `IrRegistry`, cf. doc de module.
37    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
51/// Traduit un `ColumnType` SeaORM en couple `(type, format)` OpenAPI. Volontairement pas
52/// exhaustif au sens "une variante = un mapping unique garanti stable dans le temps" — `Decimal`/
53/// `Money` restent en `string` pour ne pas perdre de précision en JSON, `Enum`/`Custom`/`Array`
54/// retombent sur un type générique plutôt que d'échouer.
55fn 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        // ColumnType est #[non_exhaustive] côté sea-query — un fallback générique plutôt que de
86        // casser la compilation à chaque variante ajoutée en amont.
87        _ => ("string", None),
88    }
89}
90
91/// Table SQL physique référencée par `def`, si `def` est un `belongs_to` scalaire simple portant
92/// `column_name` — `None` sinon (pas de correspondance, FK composite, ou relation inversée
93/// `has_one`/`has_many` où `Self` ne porte pas la colonne).
94///
95/// Point d'attention : `RelationDef::is_owner` a une sémantique inversée par rapport à son propre
96/// doc-comment dans `sea-orm 2.0.2` — `EntityTrait::belongs_to()` (où `Self` porte bien la FK)
97/// construit avec `is_owner: false` ; `has_one()`/`has_many()` (où `Self` ne la porte pas, c'est
98/// l'entité liée qui la porte) construisent avec `is_owner: true`. En clair `is_owner: true`
99/// signifie "`Self` est parent/propriétaire de la relation" (cascade-save `ActiveModelEx`), pas
100/// "porte la colonne FK" — vérifié dans `sea-orm-2.0.2/src/entity/relation.rs`
101/// (`EntityTrait::belongs_to`/`has_one`/`has_many`). Une lecture littérale du doc-comment aurait
102/// fait remonter la PK comme `references` sur les relations `has_one` inversées.
103fn 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        // FK composite (`Binary`/`Ternary`/`Many`) — non supporté, cf. #19.
109        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
120/// Produit l'IR d'une entité — fonction pure, comme `resource_openapi::<E>()`. `FieldIr::references`
121/// porte ici le nom de table SQL brut, pas encore un `resource_name` — cf. doc de module.
122pub fn resource_ir<E: MiryadResource>() -> EntityIr {
123    // `Column` (dérivé par `DeriveEntityModel`) n'implémente pas `PartialEq` — comparaison par nom
124    // (`Iden::to_string`), pas par `==` (cf. `docs/architecture.md`, "Point d'attention").
125    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/// Accumule l'IR de plusieurs entités et la sérialise — même registre-pattern que
158/// `McpToolRegistry`/`PolicyRegistry` (feature 6/5), pour rester cohérent avec le reste du crate.
159/// miryad-core fournit cette fonction ; produire le fichier (binaire dédié, ou sous-commande du
160/// binaire backend) reste à la charge de l'app — pas d'exécutable ici.
161#[derive(Debug, Default)]
162pub struct IrRegistry {
163    entities: Vec<EntityIr>,
164    /// `(table SQL physique, resource_name)` par entité enregistrée — sert uniquement à résoudre
165    /// `FieldIr::references` (nom de table brut → `resource_name`) dans `resolved_entities`.
166    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    /// Résout `FieldIr::references` (nom de table brut → `resource_name`) sur une copie des
184    /// entités enregistrées — `resource_ir::<E>()` seule ne connaît pas les autres entités,
185    /// cette résolution ne peut se faire qu'une fois toutes connues. `references` reste `None`
186    /// si la table référencée n'appartient à aucune entité enregistrée dans ce registre (le
187    /// frontend ne peut de toute façon pas lier vers une ressource absente de l'IR).
188    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            // Délibérément différent du nom de table ("tags") — rend le test de résolution
308            // (nom de table brut -> resource_name) discriminant plutôt qu'une coïncidence.
309            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    /// Table de liaison recipe<->ingredient, avec une FK supplémentaire vers `tag` — fixture pour
325    /// les tests de relations (#19) : `recipe_id`/`ingredient_id` couvrent le cas nominal,
326    /// `tag_id` le cas où `resource_name` diffère du nom de table.
327    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        // Appelée seule (hors IrRegistry), resource_ir::<E>() ne peut pas connaître les
429        // resource_name des autres entités — references porte le nom de table SQL brut.
430        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        // tag::Entity::resource_name() == "recipe-tags", mais sa table SQL est "tags" — c'est bien
447        // le nom de table qui doit apparaître ici, la résolution en resource_name est le rôle
448        // d'IrRegistry, pas de resource_ir seule.
449        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        // Cas discriminant : la table "tags" doit résoudre vers le resource_name "recipe-tags",
482        // pas rester à "tags" (ce qui prouverait un simple passthrough, pas une vraie résolution).
483        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        // recipe/ingredient/tag volontairement non enregistrées.
496
497        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        // Mauvaise colonne — pas de correspondance.
549        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        // has_one()/has_many() inversés : Self ne porte pas la colonne (is_owner: true) — cf.
557        // Point d'attention sur resolve_reference_table.
558        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}