Skip to content

Commit cb4b356

Browse files
committed
feat(ogar-from-ruff): pull-back emit — Rust codegen reference (CompiledClass -> Rust)
The transpiler's output leg. `mint` is pull-in (source -> CompiledClass); `emit` is pull-back (CompiledClass -> a target language). Reference target is Rust, mirroring `ogar-adapter-surrealql`'s role for SurrealQL DDL. `emit_rust(&CompiledClass) -> String` renders a rail struct whose fields use the consumer's WRAPPER-CONTRACT types — so "the language just needs a wrapper contract akin to lance-graph" is literal: - scalar attribute -> `OgScalar` - Many2one/HasOne -> `ToOne<Comodel>` - One2many/Many2many-> `ToMany<Comodel>` - the rail address travels as `pub const <NAME>_CLASSID: u32` - computed fields -> doc lines (the compute behaviour is the "impossible" 15% — it lands as an adapter, not inline codegen) account.move -> `struct AccountMove { name: OgScalar, partner_id: ToOne<ResPartner>, line_ids: ToMany<AccountMoveLine> }` + ACCOUNT_MOVE_CLASSID = 0x00020202. Scalars emit `OgScalar` uniformly until the `field_type` capture lands (ruff follow-up); the seam doesn't change when it does. Doc (OGAR-TRANSPILE-SUBSTRATE.md) updated: both transpile legs (pull-in lift+mint, pull-back emit) now shipped + probe-verified. Verified via probe (real ogar-vocab + ruff main): 36 tests pass (2 new emit tests), clippy -D warnings clean. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 57c2691 commit cb4b356

3 files changed

Lines changed: 196 additions & 6 deletions

File tree

crates/ogar-from-ruff/src/emit.rs

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
//! Pull-back emit — the OGAR transpile substrate's *output* leg.
2+
//!
3+
//! [`mint`](crate::mint) is the pull-in half (source → `CompiledClass`); this
4+
//! is the pull-back half: render a [`CompiledClass`] back into a target
5+
//! language's source. The reference emitter here is **Rust**
6+
//! ([`emit_rust`]); it mirrors the role `ogar-adapter-surrealql` plays for
7+
//! SurrealQL DDL. Other targets (`emit_csharp`, `emit_python`, …) follow the
8+
//! same `&CompiledClass -> String` seam.
9+
//!
10+
//! **The wrapper-contract pivot.** The emitted struct does not inline native
11+
//! types — it uses the consumer's thin wrapper-contract types: `OgScalar` for
12+
//! a column, `ToOne<T>` / `ToMany<T>` for a relation. So "the language just
13+
//! needs to put a wrapper contract akin to lance-graph" is literal: a Rust
14+
//! consumer provides those three aliases (its wrapper contract) and the
15+
//! emitted, rail-shaped class compiles. The `classid` is emitted as a `const`
16+
//! — the rail address travels with the class.
17+
//!
18+
//! Scalar attributes currently emit `OgScalar` uniformly: the Odoo field type
19+
//! (`Char` / `Monetary` / …) is not yet carried on the SPO `Field`, so there
20+
//! is nothing to specialise on. When the `field_type` capture lands (the ruff
21+
//! follow-up), `OgScalar` refines to the mapped concrete type with no change
22+
//! to this seam.
23+
24+
use ogar_vocab::AssociationKind;
25+
26+
use crate::mint::CompiledClass;
27+
28+
/// Emit a [`CompiledClass`] as Rust source: a struct whose fields use the
29+
/// consumer's wrapper-contract types (`OgScalar` / `ToOne` / `ToMany`),
30+
/// prefixed by its rail `classid` const and a facet/concept doc line.
31+
/// Computed fields are emitted as trailing doc lines (the compute behaviour
32+
/// is the "impossible" 15% — it lands as an adapter, not inline codegen).
33+
#[must_use]
34+
pub fn emit_rust(cc: &CompiledClass) -> String {
35+
let ty = pascal_case(&cc.class.name);
36+
let mut out = String::new();
37+
38+
out.push_str(&format!(
39+
"/// Rail class `{}` — classid `0x{:08X}` (concept `0x{:04X}`).\n",
40+
cc.class.name,
41+
cc.facet.facet_classid(),
42+
cc.facet.facet_classid() as u16,
43+
));
44+
out.push_str(&format!(
45+
"pub const {}_CLASSID: u32 = 0x{:08X};\n\n",
46+
screaming_snake(&cc.class.name),
47+
cc.facet.facet_classid(),
48+
));
49+
50+
out.push_str(&format!("pub struct {ty} {{\n"));
51+
for attr in &cc.class.attributes {
52+
out.push_str(&format!(" pub {}: OgScalar,\n", attr.name));
53+
}
54+
for assoc in &cc.class.associations {
55+
let target = pascal_case(assoc.class_name.as_deref().unwrap_or(&assoc.name));
56+
let field_ty = match assoc.kind {
57+
AssociationKind::BelongsTo | AssociationKind::HasOne => format!("ToOne<{target}>"),
58+
AssociationKind::HasMany | AssociationKind::HasAndBelongsToMany => {
59+
format!("ToMany<{target}>")
60+
}
61+
// A future AssociationKind defaults to a single typed reference.
62+
_ => format!("ToOne<{target}>"),
63+
};
64+
out.push_str(&format!(" pub {}: {field_ty},\n", assoc.name));
65+
}
66+
out.push_str("}\n");
67+
68+
for c in &cc.class.computed_fields {
69+
out.push_str(&format!(
70+
"// computed: {} <- {}({})\n",
71+
c.field,
72+
c.compute_method,
73+
c.depends.join(", "),
74+
));
75+
}
76+
77+
out
78+
}
79+
80+
/// `account.move` / `account_move` → `AccountMove`. Treats both `.` and `_`
81+
/// as word separators (Odoo dotted comodels and underscore-normalised model
82+
/// names both arrive here).
83+
fn pascal_case(name: &str) -> String {
84+
name.split(['.', '_'])
85+
.filter(|seg| !seg.is_empty())
86+
.map(|seg| {
87+
let mut chars = seg.chars();
88+
chars.next().map_or_else(String::new, |first| {
89+
first.to_uppercase().collect::<String>() + chars.as_str()
90+
})
91+
})
92+
.collect()
93+
}
94+
95+
/// `account_move` → `ACCOUNT_MOVE` (for the `*_CLASSID` const name).
96+
fn screaming_snake(name: &str) -> String {
97+
name.split(['.', '_'])
98+
.filter(|seg| !seg.is_empty())
99+
.map(str::to_uppercase)
100+
.collect::<Vec<_>>()
101+
.join("_")
102+
}
103+
104+
#[cfg(test)]
105+
mod tests {
106+
use super::*;
107+
use crate::mint::compile_graph_python;
108+
use ogar_vocab::ports::OdooPort;
109+
use ruff_spo_triplet::{Field, Function, Model, ModelGraph};
110+
111+
fn account_move_graph() -> ModelGraph {
112+
let mut m = Model::new("account_move");
113+
m.fields.push(Field {
114+
name: "name".to_string(),
115+
..Default::default()
116+
});
117+
m.fields.push(Field {
118+
name: "partner_id".to_string(),
119+
target: Some("res.partner".to_string()),
120+
relation_kind: Some("many2one".to_string()),
121+
..Default::default()
122+
});
123+
m.fields.push(Field {
124+
name: "line_ids".to_string(),
125+
target: Some("account.move.line".to_string()),
126+
inverse_name: Some("move_id".to_string()),
127+
relation_kind: Some("one2many".to_string()),
128+
..Default::default()
129+
});
130+
m.fields.push(Field {
131+
name: "amount_total".to_string(),
132+
emitted_by: Some("_compute_amount".to_string()),
133+
depends_on: vec!["line_ids.balance".to_string()],
134+
..Default::default()
135+
});
136+
m.functions.push(Function {
137+
name: "_compute_amount".to_string(),
138+
reads: Vec::new(),
139+
raises: Vec::new(),
140+
traverses: Vec::new(),
141+
});
142+
let mut g = ModelGraph::new("odoo");
143+
g.models.push(m);
144+
g
145+
}
146+
147+
#[test]
148+
fn emits_rail_struct_with_wrapper_contract_types() {
149+
let cc = &compile_graph_python::<OdooPort>(&account_move_graph())[0];
150+
let rust = emit_rust(cc);
151+
152+
// The rail address travels as a const.
153+
assert!(rust.contains("pub const ACCOUNT_MOVE_CLASSID: u32 = 0x00020202;"));
154+
// The struct is PascalCase.
155+
assert!(rust.contains("pub struct AccountMove {"));
156+
// Scalar -> the wrapper's OgScalar.
157+
assert!(rust.contains("pub name: OgScalar,"));
158+
// Many2one -> ToOne<comodel>; One2many -> ToMany<comodel>.
159+
assert!(
160+
rust.contains("pub partner_id: ToOne<ResPartner>,"),
161+
"got:\n{rust}"
162+
);
163+
assert!(
164+
rust.contains("pub line_ids: ToMany<AccountMoveLine>,"),
165+
"got:\n{rust}"
166+
);
167+
// Computed behaviour is a doc line (the 15% lands as an adapter).
168+
assert!(rust.contains("// computed: amount_total <- _compute_amount(line_ids.balance)"));
169+
}
170+
171+
#[test]
172+
fn pascal_case_handles_dotted_and_underscored() {
173+
assert_eq!(pascal_case("account.move.line"), "AccountMoveLine");
174+
assert_eq!(pascal_case("account_move"), "AccountMove");
175+
assert_eq!(pascal_case("res.partner"), "ResPartner");
176+
assert_eq!(screaming_snake("account_move"), "ACCOUNT_MOVE");
177+
}
178+
}

crates/ogar-from-ruff/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
#![forbid(unsafe_code)]
6565
#![warn(missing_docs)]
6666

67+
pub mod emit;
6768
pub mod mint;
6869

6970
use ogar_vocab::{

docs/OGAR-TRANSPILE-SUBSTRATE.md

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -230,11 +230,20 @@ correct because of the `relation_kind` predicate (ruff#35): `target` +
230230
- `lift_model_graph_python` + the `project_odoo_fields` schema projection (OGAR #131/#132).
231231
- **`ogar-from-ruff::mint`** — per-class minting: `mint_graph<P>`,
232232
`CompiledClass`, `compile_graph_python<P>` (OGAR #132).
233+
- **`ogar-from-ruff::emit`** — the pull-back **codegen** leg, reference
234+
target Rust: `emit_rust(&CompiledClass) -> String` renders a rail struct
235+
whose fields use the consumer's wrapper-contract types (`OgScalar` /
236+
`ToOne<T>` / `ToMany<T>`) + a `*_CLASSID` const (OGAR #132).
237+
`account.move → struct AccountMove { name: OgScalar, partner_id:
238+
ToOne<ResPartner>, line_ids: ToMany<AccountMoveLine> }`.
233239

234240
**Next (the transpiler direction):**
235-
1. **Pull-back emit**`ogar-emit-<lang>` adapters (`CompiledClass → <lang>`),
236-
mirroring `ogar-adapter-surrealql`. Plus the thin runtime wrapper-contract
237-
pattern for C#/Python (lance-graph-contract is the Rust reference).
241+
1. **Pull-back emit, breadth + depth**`emit_csharp` / `emit_python`
242+
targets on the same `&CompiledClass -> String` seam; refine `OgScalar`
243+
to mapped concrete types once the `field_type` capture lands (ruff
244+
follow-up); extract the family into dedicated `ogar-emit-<lang>` crates
245+
mirroring `ogar-adapter-surrealql`. The runtime wrapper-contract mode
246+
(lance-graph-contract for Rust) is the C#/Python sibling.
238247
2. **Thin the consumer**`odoo-rs` collapses to a `compile_graph::<OdooPort>`
239248
caller + the `od-posting` GoBD adapter (the 15%).
240249
3. **Scale** — run the `odoo_blueprint` 404 entities through `compile_graph`;
@@ -267,6 +276,8 @@ correct because of the `relation_kind` predicate (ruff#35): `target` +
267276

268277
---
269278

270-
*Authored alongside the `ogar-from-ruff::mint` per-class minting (OGAR #132).
271-
The pull-in + mint legs are shipped and probe-verified; the pull-back emit and
272-
consumer-thinning legs (§6.1, §6.2) are the next deliverables.*
279+
*Authored alongside the `ogar-from-ruff::mint` per-class minting + the
280+
`ogar-from-ruff::emit` Rust pull-back reference (OGAR #132). Both transpile
281+
legs — pull-in (lift + mint) and pull-back (emit) — are shipped and
282+
probe-verified; breadth (more target languages, type-capture refinement) and
283+
the consumer-thinning leg (§6.2) are the next deliverables.*

0 commit comments

Comments
 (0)