|
| 1 | +//! Per-class minting — the OGAR transpile substrate's addressing step. |
| 2 | +//! |
| 3 | +//! [`lift_model_graph_python`](crate::lift_model_graph_python) turns a |
| 4 | +//! [`ModelGraph`] into the schema-carrying [`Class`]es. This module adds the |
| 5 | +//! other half: the 16-byte **rail facet** for each class, with its render |
| 6 | +//! classid resolved through a language/app [`PortSpec`] |
| 7 | +//! (`OdooPort` / `OpenProjectPort` / …). The pair is the unit a consumer |
| 8 | +//! pulls back — language-agnostic compiled business logic, addressed by |
| 9 | +//! classid: |
| 10 | +//! |
| 11 | +//! ```text |
| 12 | +//! ModelGraph (any ogar-from-<lang> frontend) |
| 13 | +//! │ lift_model_graph_* -> Vec<Class> (the schema) |
| 14 | +//! │ expand + mint_with_classid -> Mint (the rail facets) |
| 15 | +//! ▼ |
| 16 | +//! Vec<CompiledClass { class, facet }> |
| 17 | +//! ▲ |
| 18 | +//! a consumer (odoo-rs, medcare-rs, …) pulls these via its thin wrapper |
| 19 | +//! contract (lance-graph-contract is Rust's) — the "impossible" 15 % |
| 20 | +//! (intrusive / stateful logic) stays a per-language adapter + ClassView. |
| 21 | +//! ``` |
| 22 | +//! |
| 23 | +//! The classid is the cross-app join key: the **low u16** is the shared |
| 24 | +//! concept ([`PortSpec::class_id`] → the OGAR codebook), the **high u16** is |
| 25 | +//! the app render prefix ([`PortSpec::APP_PREFIX`]). So `account.move` lands |
| 26 | +//! as `0x0002_0202` under [`OdooPort`](ogar_vocab::ports::OdooPort) and an |
| 27 | +//! OpenProject `WorkPackage` lands as `0x0001_0102` under |
| 28 | +//! [`OpenProjectPort`](ogar_vocab::ports::OpenProjectPort) — different render |
| 29 | +//! skins, the same conceptual identity where they converge. |
| 30 | +
|
| 31 | +use ogar_vocab::app::render_classid_for; |
| 32 | +use ogar_vocab::ports::PortSpec; |
| 33 | +use ogar_vocab::Class; |
| 34 | +use ruff_spo_address::{mint_with_classid, Facet, Mint}; |
| 35 | +use ruff_spo_triplet::{expand, ModelGraph}; |
| 36 | + |
| 37 | +use crate::lift_model_graph_python; |
| 38 | + |
| 39 | +/// A class compiled to its rail-shaped, language-agnostic form: the lifted |
| 40 | +/// schema ([`Class`]) plus its 16-byte address ([`Facet`]). This is what a |
| 41 | +/// consumer pulls from the OGAR substrate and renders through its own |
| 42 | +/// `ClassView` — the schema says *what the class is*, the facet says *where |
| 43 | +/// it lives* (classid + the `part_of` / `is_a` cascade). |
| 44 | +#[derive(Debug, Clone)] |
| 45 | +pub struct CompiledClass { |
| 46 | + /// The lifted schema — attributes / associations / computed fields. |
| 47 | + pub class: Class, |
| 48 | + /// The 16-byte rail facet: render classid + the `part_of` / `is_a` |
| 49 | + /// tier chains. |
| 50 | + pub facet: Facet, |
| 51 | +} |
| 52 | + |
| 53 | +/// Mint the rail facet for every node in `graph`, resolving each node's |
| 54 | +/// render classid through port `P`. Frontend-agnostic: any |
| 55 | +/// `ogar-from-<lang>` produces the same [`ModelGraph`], so the mint is |
| 56 | +/// shared. The returned [`Mint`] addresses every node (model *and* member) |
| 57 | +/// — look one up with [`Mint::facet`]. |
| 58 | +#[must_use] |
| 59 | +pub fn mint_graph<P: PortSpec>(graph: &ModelGraph) -> Mint { |
| 60 | + let triples = expand(graph); |
| 61 | + mint_with_classid(&triples, |node| classid_for_node::<P>(node)) |
| 62 | +} |
| 63 | + |
| 64 | +/// Compile a Python/Odoo [`ModelGraph`] into rail-shaped [`CompiledClass`]es: |
| 65 | +/// lift each model's schema and pair it with its minted facet, classid via |
| 66 | +/// port `P`. Declaration order is preserved (mirrors |
| 67 | +/// [`lift_model_graph_python`]). |
| 68 | +#[must_use] |
| 69 | +pub fn compile_graph_python<P: PortSpec>(graph: &ModelGraph) -> Vec<CompiledClass> { |
| 70 | + let mint = mint_graph::<P>(graph); |
| 71 | + lift_model_graph_python(graph) |
| 72 | + .into_iter() |
| 73 | + .map(|class| { |
| 74 | + let node = format!("{}:{}", graph.namespace, class.name); |
| 75 | + // A model always appears as a subject node (`rdf:type ObjectType`), |
| 76 | + // so it mints; the fallback covers a degenerate graph by stamping |
| 77 | + // the classid with empty tier chains (still a valid rail address). |
| 78 | + let facet = mint |
| 79 | + .facet(&node) |
| 80 | + .unwrap_or_else(|| Facet::from_parts(classid_for_node::<P>(&node), [0; 6], [0; 6])); |
| 81 | + CompiledClass { class, facet } |
| 82 | + }) |
| 83 | + .collect() |
| 84 | +} |
| 85 | + |
| 86 | +/// Resolve a node IRI's full render classid via port `P`. |
| 87 | +/// |
| 88 | +/// The IRI is `<ns>:<model>` or `<ns>:<model>.<member>`; members inherit |
| 89 | +/// their class's id, so we resolve the *model*'s concept and compose the |
| 90 | +/// render classid `(APP_PREFIX << 16) | concept`. Odoo models arrive |
| 91 | +/// underscore-normalised in the IRI (`account_move`) while ports key on the |
| 92 | +/// dotted name (`account.move`), so we try the dotted form first, then the |
| 93 | +/// raw form (mirrors `od-ontology`'s `concept_classid`). An unmapped model |
| 94 | +/// resolves to `0` — the bootstrap address, left for the registry / |
| 95 | +/// ref-escape path, never silently mis-stamped. |
| 96 | +fn classid_for_node<P: PortSpec>(node: &str) -> u32 { |
| 97 | + let model = model_of(node); |
| 98 | + P::class_id(&model.replace('_', ".")) |
| 99 | + .or_else(|| P::class_id(model)) |
| 100 | + .map_or(0, render_classid_for::<P>) |
| 101 | +} |
| 102 | + |
| 103 | +/// The model segment of a node IRI: strip the `<ns>:` prefix, take up to the |
| 104 | +/// first `.` (the model↔member separator). |
| 105 | +fn model_of(node: &str) -> &str { |
| 106 | + let body = node.split_once(':').map_or(node, |(_, rest)| rest); |
| 107 | + body.split_once('.').map_or(body, |(model, _)| model) |
| 108 | +} |
| 109 | + |
| 110 | +#[cfg(test)] |
| 111 | +mod tests { |
| 112 | + use super::*; |
| 113 | + use ogar_vocab::ports::{OdooPort, OpenProjectPort}; |
| 114 | + use ruff_spo_triplet::{Field, Function, Model}; |
| 115 | + |
| 116 | + // A representative `account.move` `ModelGraph`, constructed directly (the |
| 117 | + // source→ModelGraph parse is `ruff_python_spo`'s job, tested there). Carries |
| 118 | + // the relation-aware shape this session's ruff#34/#35 work added. |
| 119 | + fn account_move_graph() -> ModelGraph { |
| 120 | + let mut m = Model::new("account_move"); |
| 121 | + m.fields.push(Field { |
| 122 | + name: "name".to_string(), |
| 123 | + ..Default::default() |
| 124 | + }); |
| 125 | + m.fields.push(Field { |
| 126 | + name: "partner_id".to_string(), |
| 127 | + target: Some("res.partner".to_string()), |
| 128 | + relation_kind: Some("many2one".to_string()), |
| 129 | + ..Default::default() |
| 130 | + }); |
| 131 | + m.fields.push(Field { |
| 132 | + name: "line_ids".to_string(), |
| 133 | + target: Some("account.move.line".to_string()), |
| 134 | + inverse_name: Some("move_id".to_string()), |
| 135 | + relation_kind: Some("one2many".to_string()), |
| 136 | + ..Default::default() |
| 137 | + }); |
| 138 | + m.fields.push(Field { |
| 139 | + name: "amount_total".to_string(), |
| 140 | + emitted_by: Some("_compute_amount".to_string()), |
| 141 | + depends_on: vec!["line_ids.balance".to_string()], |
| 142 | + ..Default::default() |
| 143 | + }); |
| 144 | + m.functions.push(Function { |
| 145 | + name: "_compute_amount".to_string(), |
| 146 | + reads: Vec::new(), |
| 147 | + raises: Vec::new(), |
| 148 | + traverses: Vec::new(), |
| 149 | + }); |
| 150 | + let mut g = ModelGraph::new("odoo"); |
| 151 | + g.models.push(m); |
| 152 | + g |
| 153 | + } |
| 154 | + |
| 155 | + #[test] |
| 156 | + fn account_move_compiles_to_commercial_document_rail_class() { |
| 157 | + let graph = account_move_graph(); |
| 158 | + let compiled = compile_graph_python::<OdooPort>(&graph); |
| 159 | + assert_eq!(compiled.len(), 1); |
| 160 | + let cc = &compiled[0]; |
| 161 | + |
| 162 | + // The schema is present (the #132 field projection): partner_id / |
| 163 | + // line_ids land as associations, amount_total as a computed field. |
| 164 | + assert_eq!(cc.class.name, "account_move"); |
| 165 | + assert!( |
| 166 | + !cc.class.associations.is_empty(), |
| 167 | + "relational fields project into associations" |
| 168 | + ); |
| 169 | + assert!( |
| 170 | + !cc.class.computed_fields.is_empty(), |
| 171 | + "amount_total projects into computed_fields" |
| 172 | + ); |
| 173 | + |
| 174 | + // The rail facet carries the canonical render classid — Odoo prefix |
| 175 | + // 0x0002 | commercial_document concept 0x0202. |
| 176 | + assert_eq!(cc.facet.facet_classid(), 0x0002_0202); |
| 177 | + assert_eq!(cc.facet.facet_classid() & 0xFFFF, 0x0202, "shared concept"); |
| 178 | + assert_eq!( |
| 179 | + (cc.facet.facet_classid() >> 16) as u16, |
| 180 | + OdooPort::APP_PREFIX, |
| 181 | + "Odoo render prefix", |
| 182 | + ); |
| 183 | + } |
| 184 | + |
| 185 | + #[test] |
| 186 | + fn member_facets_inherit_the_class_render_classid() { |
| 187 | + let graph = account_move_graph(); |
| 188 | + let mint = mint_graph::<OdooPort>(&graph); |
| 189 | + // Members are addressed within their class: same render classid. |
| 190 | + for node in ["odoo:account_move", "odoo:account_move.partner_id"] { |
| 191 | + let f = mint.facet(node).unwrap_or_else(|| panic!("{node} mints")); |
| 192 | + assert_eq!(f.facet_classid(), 0x0002_0202, "{node} classid"); |
| 193 | + } |
| 194 | + } |
| 195 | + |
| 196 | + #[test] |
| 197 | + fn mint_is_port_agnostic_same_concept_different_render_prefix() { |
| 198 | + // The mint doesn't care about the source language — only the port |
| 199 | + // lens. An OpenProject WorkPackage graph minted through OpenProjectPort |
| 200 | + // lands as 0x0001_0102 (OpenProject prefix | project_work_item), the |
| 201 | + // SAME low-u16 concept Odoo/Redmine converge on, a different prefix. |
| 202 | + let mut graph = ModelGraph::new("openproject"); |
| 203 | + graph.models.push(Model::new("WorkPackage")); |
| 204 | + let mint = mint_graph::<OpenProjectPort>(&graph); |
| 205 | + let facet = mint |
| 206 | + .facet("openproject:WorkPackage") |
| 207 | + .expect("WorkPackage mints"); |
| 208 | + assert_eq!(facet.facet_classid(), 0x0001_0102); |
| 209 | + assert_eq!(facet.facet_classid() & 0xFFFF, 0x0102, "project_work_item"); |
| 210 | + assert_eq!( |
| 211 | + (facet.facet_classid() >> 16) as u16, |
| 212 | + OpenProjectPort::APP_PREFIX, |
| 213 | + ); |
| 214 | + } |
| 215 | + |
| 216 | + #[test] |
| 217 | + fn unmapped_model_mints_the_bootstrap_address_not_a_wrong_classid() { |
| 218 | + // A model with no codebook entry resolves to classid 0 (bootstrap), |
| 219 | + // never a mis-stamped foreign id. |
| 220 | + let mut graph = ModelGraph::new("odoo"); |
| 221 | + graph.models.push(Model::new("ir_cron")); |
| 222 | + let mint = mint_graph::<OdooPort>(&graph); |
| 223 | + let facet = mint.facet("odoo:ir_cron").expect("node mints"); |
| 224 | + assert_eq!(facet.facet_classid(), 0, "unmapped -> bootstrap address"); |
| 225 | + } |
| 226 | +} |
0 commit comments