2222//! **mechanical transliteration** of the same compiled spine rather than a
2323//! re-implementation — the layer-1 / layer-2 story of substrate doc §1.6.
2424//!
25- //! Scalar attributes currently emit `OgScalar` uniformly: the Odoo field type
26- //! (`Char` / `Monetary` / …) is not yet carried on the SPO `Field`, so there
27- //! is nothing to specialise on. When the `field_type` capture lands (the ruff
28- //! follow-up), `OgScalar` refines to the mapped concrete type with no change
29- //! to this seam.
25+ //! Scalar attributes emit a **concrete wrapper type** mapped from the Odoo
26+ //! constructor carried on `Attribute::type_name` (the ruff `field_type`
27+ //! predicate): `char`/`text`/`html` → `OgStr`, `integer` → `OgInt`,
28+ //! `float` → `OgFloat`, `monetary` → `OgMoney` (Decimal-backed), `boolean` →
29+ //! `OgBool`, `date`/`datetime` → `OgDate`/`OgDateTime`, `binary` → `OgBytes`,
30+ //! `selection` → `OgSelection`, `json` → `OgJson`; an untyped/unknown column
31+ //! falls back to the generic `OgScalar`. See [`og_scalar_type`].
32+ //!
33+ //! Field identifiers that collide with a target-language reserved word are
34+ //! escaped (see [`escape_ident`]) so the emitted source compiles — e.g. an
35+ //! Odoo field named `type` / `ref` becomes `r#type` / `r#ref` in Rust and
36+ //! `@type` / `@ref` in C#. Python source field names cannot be keywords (the
37+ //! Odoo source would not parse), so Python emit needs no escaping.
3038
3139use ogar_vocab:: { Association , AssociationKind } ;
3240
@@ -47,6 +55,86 @@ fn assoc_target(assoc: &Association) -> (String, bool) {
4755 ( target, is_many)
4856}
4957
58+ /// Map an Odoo field constructor (lowercased, carried on `Attribute::type_name`
59+ /// as the ruff `field_type` predicate) to the consumer wrapper-contract scalar
60+ /// type. Shared by all three emitters — the type NAMES are identical across
61+ /// languages (§1.6). `None` (type not captured) and any unrecognised
62+ /// constructor fall back to the generic `OgScalar`, so the emit is always
63+ /// well-typed. `monetary` → `OgMoney` is Decimal-backed (the ERP money
64+ /// doctrine), never a float.
65+ fn og_scalar_type ( type_name : Option < & str > ) -> & ' static str {
66+ match type_name {
67+ Some ( "char" | "text" | "html" ) => "OgStr" ,
68+ Some ( "integer" ) => "OgInt" ,
69+ Some ( "float" ) => "OgFloat" ,
70+ Some ( "monetary" ) => "OgMoney" ,
71+ Some ( "boolean" ) => "OgBool" ,
72+ Some ( "date" ) => "OgDate" ,
73+ Some ( "datetime" ) => "OgDateTime" ,
74+ Some ( "binary" | "image" ) => "OgBytes" ,
75+ Some ( "selection" ) => "OgSelection" ,
76+ Some ( "json" ) => "OgJson" ,
77+ _ => "OgScalar" ,
78+ }
79+ }
80+
81+ /// Target language for [`escape_ident`].
82+ #[ derive( Clone , Copy ) ]
83+ enum Lang {
84+ Rust ,
85+ CSharp ,
86+ Python ,
87+ }
88+
89+ /// Escape an emitted field/member identifier that collides with a
90+ /// target-language reserved word, so the generated source compiles.
91+ ///
92+ /// Odoo field names are verbatim `snake_case`; some (`type`, `ref`, `move`, …)
93+ /// are Rust and/or C# keywords, and a raw `pub type: …` / `public … type` would
94+ /// not compile. Rust uses raw identifiers (`r#type`); the four that cannot be
95+ /// raw (`crate`/`self`/`super`/`Self`) get a trailing `_`. C# prefixes `@`
96+ /// (`@ref`). Python source field names cannot be keywords (the Odoo class body
97+ /// would not parse), so Python returns the name unchanged.
98+ fn escape_ident ( name : & str , lang : Lang ) -> String {
99+ match lang {
100+ Lang :: Rust if matches ! ( name, "crate" | "self" | "super" | "Self" ) => format ! ( "{name}_" ) ,
101+ Lang :: Rust if is_rust_keyword ( name) => format ! ( "r#{name}" ) ,
102+ Lang :: CSharp if is_csharp_keyword ( name) => format ! ( "@{name}" ) ,
103+ _ => name. to_string ( ) ,
104+ }
105+ }
106+
107+ /// Rust strict + reserved keywords (2021 edition + reserved-for-future).
108+ fn is_rust_keyword ( s : & str ) -> bool {
109+ matches ! (
110+ s,
111+ "as" | "break" | "const" | "continue" | "crate" | "dyn" | "else" | "enum" | "extern"
112+ | "false" | "fn" | "for" | "if" | "impl" | "in" | "let" | "loop" | "match" | "mod"
113+ | "move" | "mut" | "pub" | "ref" | "return" | "self" | "Self" | "static" | "struct"
114+ | "super" | "trait" | "true" | "type" | "unsafe" | "use" | "where" | "while"
115+ | "async" | "await" | "abstract" | "become" | "box" | "do" | "final" | "macro"
116+ | "override" | "priv" | "typeof" | "unsized" | "virtual" | "yield" | "try" | "gen"
117+ )
118+ }
119+
120+ /// C# reserved keywords. Contextual keywords (`type`, `record`, `var`, …) are
121+ /// legal identifiers in C#, so they are deliberately omitted.
122+ fn is_csharp_keyword ( s : & str ) -> bool {
123+ matches ! (
124+ s,
125+ "abstract" | "as" | "base" | "bool" | "break" | "byte" | "case" | "catch" | "char"
126+ | "checked" | "class" | "const" | "continue" | "decimal" | "default" | "delegate"
127+ | "do" | "double" | "else" | "enum" | "event" | "explicit" | "extern" | "false"
128+ | "finally" | "fixed" | "float" | "for" | "foreach" | "goto" | "if" | "implicit"
129+ | "in" | "int" | "interface" | "internal" | "is" | "lock" | "long" | "namespace"
130+ | "new" | "null" | "object" | "operator" | "out" | "override" | "params" | "private"
131+ | "protected" | "public" | "readonly" | "ref" | "return" | "sbyte" | "sealed"
132+ | "short" | "sizeof" | "stackalloc" | "static" | "string" | "struct" | "switch"
133+ | "this" | "throw" | "true" | "try" | "typeof" | "uint" | "ulong" | "unchecked"
134+ | "unsafe" | "ushort" | "using" | "virtual" | "void" | "volatile" | "while"
135+ )
136+ }
137+
50138/// Emit a [`CompiledClass`] as Rust source: a struct whose fields use the
51139/// consumer's wrapper-contract types (`OgScalar` / `ToOne` / `ToMany`),
52140/// prefixed by its rail `classid` const and a facet/concept doc line.
@@ -71,7 +159,11 @@ pub fn emit_rust(cc: &CompiledClass) -> String {
71159
72160 out. push_str ( & format ! ( "pub struct {ty} {{\n " ) ) ;
73161 for attr in & cc. class . attributes {
74- out. push_str ( & format ! ( " pub {}: OgScalar,\n " , attr. name) ) ;
162+ out. push_str ( & format ! (
163+ " pub {}: {},\n " ,
164+ escape_ident( & attr. name, Lang :: Rust ) ,
165+ og_scalar_type( attr. type_name. as_deref( ) ) ,
166+ ) ) ;
75167 }
76168 for assoc in & cc. class . associations {
77169 let ( target, is_many) = assoc_target ( assoc) ;
@@ -80,7 +172,10 @@ pub fn emit_rust(cc: &CompiledClass) -> String {
80172 } else {
81173 format ! ( "ToOne<{target}>" )
82174 } ;
83- out. push_str ( & format ! ( " pub {}: {field_ty},\n " , assoc. name) ) ;
175+ out. push_str ( & format ! (
176+ " pub {}: {field_ty},\n " ,
177+ escape_ident( & assoc. name, Lang :: Rust ) ,
178+ ) ) ;
84179 }
85180 out. push_str ( "}\n " ) ;
86181
@@ -105,10 +200,10 @@ pub fn emit_rust(cc: &CompiledClass) -> String {
105200/// codegen mode of the C# SDK (substrate doc §1.6); a host compiles the emitted
106201/// record into an assembly — the strongest "compiled, not parsed" form.
107202///
108- /// Field/member identifiers are emitted **verbatim** ( Odoo `snake_case`),
109- /// matching [`emit_rust`]'s wire fidelity; idiomatic PascalCase member casing
110- /// is a future refinement on this same seam (cf. the `OgScalar` `field_type`
111- /// note above) .
203+ /// Field/member identifiers keep their Odoo `snake_case` spelling (matching
204+ /// [`emit_rust`]'s wire fidelity), reserved-word-escaped via [`escape_ident`]
205+ /// (`@ref`); idiomatic PascalCase member casing is a future refinement on this
206+ /// same seam .
112207#[ must_use]
113208pub fn emit_csharp ( cc : & CompiledClass ) -> String {
114209 let ty = pascal_case ( & cc. class . name ) ;
@@ -127,8 +222,9 @@ pub fn emit_csharp(cc: &CompiledClass) -> String {
127222 ) ) ;
128223 for attr in & cc. class . attributes {
129224 out. push_str ( & format ! (
130- " public OgScalar {} {{ get; init; }}\n " ,
131- attr. name
225+ " public {} {} {{ get; init; }}\n " ,
226+ og_scalar_type( attr. type_name. as_deref( ) ) ,
227+ escape_ident( & attr. name, Lang :: CSharp ) ,
132228 ) ) ;
133229 }
134230 for assoc in & cc. class . associations {
@@ -140,7 +236,7 @@ pub fn emit_csharp(cc: &CompiledClass) -> String {
140236 } ;
141237 out. push_str ( & format ! (
142238 " public {field_ty} {} {{ get; init; }}\n " ,
143- assoc. name
239+ escape_ident ( & assoc. name, Lang :: CSharp ) ,
144240 ) ) ;
145241 }
146242 for c in & cc. class . computed_fields {
@@ -182,7 +278,11 @@ pub fn emit_python(cc: &CompiledClass) -> String {
182278 cc. facet. facet_classid( ) ,
183279 ) ) ;
184280 for attr in & cc. class . attributes {
185- out. push_str ( & format ! ( " {}: OgScalar\n " , attr. name) ) ;
281+ out. push_str ( & format ! (
282+ " {}: {}\n " ,
283+ escape_ident( & attr. name, Lang :: Python ) ,
284+ og_scalar_type( attr. type_name. as_deref( ) ) ,
285+ ) ) ;
186286 }
187287 for assoc in & cc. class . associations {
188288 let ( target, is_many) = assoc_target ( assoc) ;
@@ -191,7 +291,10 @@ pub fn emit_python(cc: &CompiledClass) -> String {
191291 } else {
192292 format ! ( "ToOne[\" {target}\" ]" )
193293 } ;
194- out. push_str ( & format ! ( " {}: {field_ty}\n " , assoc. name) ) ;
294+ out. push_str ( & format ! (
295+ " {}: {field_ty}\n " ,
296+ escape_ident( & assoc. name, Lang :: Python ) ,
297+ ) ) ;
195298 }
196299 for c in & cc. class . computed_fields {
197300 out. push_str ( & format ! (
@@ -240,6 +343,18 @@ mod tests {
240343 let mut m = Model :: new ( "account_move" ) ;
241344 m. fields . push ( Field {
242345 name : "name" . to_string ( ) ,
346+ field_type : Some ( "char" . to_string ( ) ) ,
347+ ..Default :: default ( )
348+ } ) ;
349+ // A field whose name is a Rust + C# reserved word — exercises escape_ident.
350+ m. fields . push ( Field {
351+ name : "ref" . to_string ( ) ,
352+ field_type : Some ( "char" . to_string ( ) ) ,
353+ ..Default :: default ( )
354+ } ) ;
355+ // No field_type → the OgScalar fallback path.
356+ m. fields . push ( Field {
357+ name : "narration" . to_string ( ) ,
243358 ..Default :: default ( )
244359 } ) ;
245360 m. fields . push ( Field {
@@ -255,8 +370,10 @@ mod tests {
255370 relation_kind : Some ( "one2many" . to_string ( ) ) ,
256371 ..Default :: default ( )
257372 } ) ;
373+ // Monetary + computed → OgMoney (Decimal-backed) AND a computed doc line.
258374 m. fields . push ( Field {
259375 name : "amount_total" . to_string ( ) ,
376+ field_type : Some ( "monetary" . to_string ( ) ) ,
260377 emitted_by : Some ( "_compute_amount" . to_string ( ) ) ,
261378 depends_on : vec ! [ "line_ids.balance" . to_string( ) ] ,
262379 ..Default :: default ( )
@@ -281,8 +398,14 @@ mod tests {
281398 assert ! ( rust. contains( "pub const ACCOUNT_MOVE_CLASSID: u32 = 0x00020202;" ) ) ;
282399 // The struct is PascalCase.
283400 assert ! ( rust. contains( "pub struct AccountMove {" ) ) ;
284- // Scalar -> the wrapper's OgScalar.
285- assert ! ( rust. contains( "pub name: OgScalar," ) ) ;
401+ // Typed scalar: char -> OgStr.
402+ assert ! ( rust. contains( "pub name: OgStr," ) , "got:\n {rust}" ) ;
403+ // Reserved-word field name -> raw identifier r#ref (and still typed).
404+ assert ! ( rust. contains( "pub r#ref: OgStr," ) , "got:\n {rust}" ) ;
405+ // Untyped scalar -> the OgScalar fallback.
406+ assert ! ( rust. contains( "pub narration: OgScalar," ) , "got:\n {rust}" ) ;
407+ // Monetary -> OgMoney (Decimal-backed money doctrine).
408+ assert ! ( rust. contains( "pub amount_total: OgMoney," ) , "got:\n {rust}" ) ;
286409 // Many2one -> ToOne<comodel>; One2many -> ToMany<comodel>.
287410 assert ! (
288411 rust. contains( "pub partner_id: ToOne<ResPartner>," ) ,
@@ -311,9 +434,23 @@ mod tests {
311434 cs. contains( "public sealed record AccountMove" ) ,
312435 "got:\n {cs}"
313436 ) ;
314- // Scalar -> the wrapper's OgScalar (init-only property).
437+ // Typed scalar: char -> OgStr (init-only property).
315438 assert ! (
316- cs. contains( "public OgScalar name { get; init; }" ) ,
439+ cs. contains( "public OgStr name { get; init; }" ) ,
440+ "got:\n {cs}"
441+ ) ;
442+ // Reserved-word field name -> @-escaped C# identifier (and still typed).
443+ assert ! (
444+ cs. contains( "public OgStr @ref { get; init; }" ) ,
445+ "got:\n {cs}"
446+ ) ;
447+ // Untyped scalar -> the OgScalar fallback; monetary -> OgMoney.
448+ assert ! (
449+ cs. contains( "public OgScalar narration { get; init; }" ) ,
450+ "got:\n {cs}"
451+ ) ;
452+ assert ! (
453+ cs. contains( "public OgMoney amount_total { get; init; }" ) ,
317454 "got:\n {cs}"
318455 ) ;
319456 // Many2one -> ToOne<comodel>; One2many -> ToMany<comodel> (shared <T> syntax).
@@ -345,8 +482,14 @@ mod tests {
345482 // A PascalCase @dataclass.
346483 assert ! ( py. contains( "@dataclass" ) , "got:\n {py}" ) ;
347484 assert ! ( py. contains( "class AccountMove:" ) , "got:\n {py}" ) ;
348- // Scalar -> OgScalar annotation.
349- assert ! ( py. contains( " name: OgScalar" ) , "got:\n {py}" ) ;
485+ // Typed scalar: char -> OgStr annotation.
486+ assert ! ( py. contains( " name: OgStr" ) , "got:\n {py}" ) ;
487+ // Reserved-word field name needs NO escaping in Python (Odoo source
488+ // field names cannot be Python keywords); still typed.
489+ assert ! ( py. contains( " ref: OgStr" ) , "got:\n {py}" ) ;
490+ // Untyped scalar -> OgScalar fallback; monetary -> OgMoney.
491+ assert ! ( py. contains( " narration: OgScalar" ) , "got:\n {py}" ) ;
492+ assert ! ( py. contains( " amount_total: OgMoney" ) , "got:\n {py}" ) ;
350493 // Relations use [T] subscripts with forward-ref comodels (not <T>).
351494 assert ! (
352495 py. contains( " partner_id: ToOne[\" ResPartner\" ]" ) ,
@@ -369,6 +512,10 @@ mod tests {
369512 // syntax differs. Assert the shared vocabulary across all three.
370513 let cc = & compile_graph_python :: < OdooPort > ( & account_move_graph ( ) ) [ 0 ] ;
371514 for src in [ emit_rust ( cc) , emit_csharp ( cc) , emit_python ( cc) ] {
515+ // Typed scalar wrappers (mapped from field_type) are shared vocab.
516+ assert ! ( src. contains( "OgStr" ) , "OgStr in every emitter" ) ;
517+ assert ! ( src. contains( "OgMoney" ) , "OgMoney in every emitter" ) ;
518+ // The untyped fallback is shared too.
372519 assert ! ( src. contains( "OgScalar" ) , "OgScalar in every emitter" ) ;
373520 assert ! ( src. contains( "ToOne" ) , "ToOne in every emitter" ) ;
374521 assert ! ( src. contains( "ToMany" ) , "ToMany in every emitter" ) ;
@@ -387,4 +534,42 @@ mod tests {
387534 assert_eq ! ( pascal_case( "res.partner" ) , "ResPartner" ) ;
388535 assert_eq ! ( screaming_snake( "account_move" ) , "ACCOUNT_MOVE" ) ;
389536 }
537+
538+ #[ test]
539+ fn og_scalar_type_maps_odoo_constructors ( ) {
540+ assert_eq ! ( og_scalar_type( Some ( "char" ) ) , "OgStr" ) ;
541+ assert_eq ! ( og_scalar_type( Some ( "text" ) ) , "OgStr" ) ;
542+ assert_eq ! ( og_scalar_type( Some ( "html" ) ) , "OgStr" ) ;
543+ assert_eq ! ( og_scalar_type( Some ( "integer" ) ) , "OgInt" ) ;
544+ assert_eq ! ( og_scalar_type( Some ( "float" ) ) , "OgFloat" ) ;
545+ assert_eq ! ( og_scalar_type( Some ( "monetary" ) ) , "OgMoney" ) ;
546+ assert_eq ! ( og_scalar_type( Some ( "boolean" ) ) , "OgBool" ) ;
547+ assert_eq ! ( og_scalar_type( Some ( "date" ) ) , "OgDate" ) ;
548+ assert_eq ! ( og_scalar_type( Some ( "datetime" ) ) , "OgDateTime" ) ;
549+ assert_eq ! ( og_scalar_type( Some ( "binary" ) ) , "OgBytes" ) ;
550+ assert_eq ! ( og_scalar_type( Some ( "selection" ) ) , "OgSelection" ) ;
551+ assert_eq ! ( og_scalar_type( Some ( "json" ) ) , "OgJson" ) ;
552+ // Unknown constructor and absent type both fall back to OgScalar.
553+ assert_eq ! ( og_scalar_type( Some ( "reference" ) ) , "OgScalar" ) ;
554+ assert_eq ! ( og_scalar_type( None ) , "OgScalar" ) ;
555+ }
556+
557+ #[ test]
558+ fn escape_ident_per_language_reserved_words ( ) {
559+ // Rust: raw identifiers for keywords; the four non-raw-able get a suffix.
560+ assert_eq ! ( escape_ident( "ref" , Lang :: Rust ) , "r#ref" ) ;
561+ assert_eq ! ( escape_ident( "type" , Lang :: Rust ) , "r#type" ) ;
562+ assert_eq ! ( escape_ident( "move" , Lang :: Rust ) , "r#move" ) ;
563+ assert_eq ! ( escape_ident( "self" , Lang :: Rust ) , "self_" ) ;
564+ assert_eq ! ( escape_ident( "crate" , Lang :: Rust ) , "crate_" ) ;
565+ assert_eq ! ( escape_ident( "amount" , Lang :: Rust ) , "amount" ) ;
566+ // C#: @-escape reserved words; contextual keywords (type) stay legal.
567+ assert_eq ! ( escape_ident( "ref" , Lang :: CSharp ) , "@ref" ) ;
568+ assert_eq ! ( escape_ident( "lock" , Lang :: CSharp ) , "@lock" ) ;
569+ assert_eq ! ( escape_ident( "type" , Lang :: CSharp ) , "type" ) ;
570+ assert_eq ! ( escape_ident( "amount" , Lang :: CSharp ) , "amount" ) ;
571+ // Python: no escaping (Odoo source field names cannot be keywords).
572+ assert_eq ! ( escape_ident( "ref" , Lang :: Python ) , "ref" ) ;
573+ assert_eq ! ( escape_ident( "type" , Lang :: Python ) , "type" ) ;
574+ }
390575}
0 commit comments