|
66 | 66 |
|
67 | 67 | use ogar_vocab::{ |
68 | 68 | canonical_concept, ActionDef, Association, AssociationKind, Attribute, Callback, Class, |
69 | | - EnumDecl, EnumSource, Inheritance, Language, Scope, Validation, |
| 69 | + ComputedField, EnumDecl, EnumSource, Inheritance, Language, Scope, Validation, |
70 | 70 | }; |
71 | 71 | use ruff_spo_triplet::{ |
72 | 72 | AssocDecl, AssocKind, AttrDecl, AttrKind, Callback as RuffCallback, ConcernKind, Model, |
@@ -181,9 +181,69 @@ fn lift_model_with_language(model: &Model, language: Language) -> Class { |
181 | 181 | class.callbacks = model.callbacks.iter().map(lift_callback).collect(); |
182 | 182 | class.validations = model.validations.iter().filter_map(lift_validation).collect(); |
183 | 183 | class.default_scope = lift_default_scope(model); |
| 184 | + // Rails carries its schema in the AR-DSL vectors lifted above; an Odoo |
| 185 | + // model instead declares everything as `fields.X(...)`, which lands in |
| 186 | + // the core-7 `Model::fields` vector (empty for Rails). Project it so the |
| 187 | + // Python lift doesn't drop the schema (Codex P1, PR #131). |
| 188 | + if matches!(language, Language::Python) { |
| 189 | + project_odoo_fields(&mut class, model); |
| 190 | + } |
184 | 191 | class |
185 | 192 | } |
186 | 193 |
|
| 194 | +/// Project an Odoo model's core-7 [`Model::fields`] onto the |
| 195 | +/// schema-carrying [`Class`] columns. Python-only: Rails models keep their |
| 196 | +/// schema in `model.attributes` / `model.associations` (lifted separately), |
| 197 | +/// and ALSO populate `model.fields` (DB columns), so projecting fields for |
| 198 | +/// Rails would double-count. Odoo leaves the AR vectors empty and puts |
| 199 | +/// everything in `fields`. |
| 200 | +/// |
| 201 | +/// Per-field mapping: |
| 202 | +/// - relational field (`target` set) → [`Association`]; the kind comes from |
| 203 | +/// the field's cardinality (`relation_kind`), `class_name` is the raw |
| 204 | +/// comodel, `inverse_of` the One2many inverse. |
| 205 | +/// - non-relational field → [`Attribute`] (name only — the Odoo field type |
| 206 | +/// is not yet carried on the SPO `Field`; a follow-up). |
| 207 | +/// - compute field (`emitted_by` set) → [`ComputedField`] (method + |
| 208 | +/// `@api.depends`), in addition to its Attribute / Association above. |
| 209 | +fn project_odoo_fields(class: &mut Class, model: &Model) { |
| 210 | + for field in &model.fields { |
| 211 | + if let Some(comodel) = &field.target { |
| 212 | + let kind = odoo_relation_kind(field.relation_kind.as_deref(), field.inverse_name.is_some()); |
| 213 | + let mut assoc = Association::new(kind, &field.name); |
| 214 | + assoc.class_name = Some(comodel.clone()); |
| 215 | + assoc.inverse_of = field.inverse_name.clone(); |
| 216 | + class.associations.push(assoc); |
| 217 | + } else { |
| 218 | + class.attributes.push(Attribute::new(&field.name)); |
| 219 | + } |
| 220 | + if let Some(compute_method) = &field.emitted_by { |
| 221 | + let mut computed = ComputedField::new(&field.name, compute_method); |
| 222 | + computed.depends = field.depends_on.clone(); |
| 223 | + class.computed_fields.push(computed); |
| 224 | + } |
| 225 | + } |
| 226 | +} |
| 227 | + |
| 228 | +/// Map an Odoo relation cardinality to the canonical [`AssociationKind`]. |
| 229 | +/// |
| 230 | +/// `relation_kind` (`many2one` / `one2many` / `many2many`, from ruff's |
| 231 | +/// `relation_kind` predicate) is the authoritative signal. The |
| 232 | +/// `has_inverse` fallback covers the theoretical case of a relational field |
| 233 | +/// with no recorded cardinality: an inverse implies a One2many, otherwise |
| 234 | +/// the to-one default `BelongsTo`. `target` + `inverse_name` alone cannot |
| 235 | +/// separate a Many2one from a Many2many — both are comodel-only with no |
| 236 | +/// inverse — which is exactly why `relation_kind` exists. |
| 237 | +fn odoo_relation_kind(relation_kind: Option<&str>, has_inverse: bool) -> AssociationKind { |
| 238 | + match relation_kind { |
| 239 | + Some("many2one") => AssociationKind::BelongsTo, |
| 240 | + Some("one2many") => AssociationKind::HasMany, |
| 241 | + Some("many2many") => AssociationKind::HasAndBelongsToMany, |
| 242 | + _ if has_inverse => AssociationKind::HasMany, |
| 243 | + _ => AssociationKind::BelongsTo, |
| 244 | + } |
| 245 | +} |
| 246 | + |
187 | 247 | // ───────────────────────── ProjectWorkItem role projection ───────────── |
188 | 248 | // |
189 | 249 | // Curator-side mapping: project_work_item's canonical roles vs the |
@@ -604,8 +664,8 @@ fn parse_bool(s: &str) -> Option<bool> { |
604 | 664 | mod tests { |
605 | 665 | use super::*; |
606 | 666 | use ruff_spo_triplet::{ |
607 | | - ActsAs, AssocDecl, AttrDecl, Callback as RuffCallback, ConcernRef, Function, ScopeDecl, |
608 | | - Validation as RuffValidation, |
| 667 | + ActsAs, AssocDecl, AttrDecl, Callback as RuffCallback, ConcernRef, Field, Function, |
| 668 | + ScopeDecl, Validation as RuffValidation, |
609 | 669 | }; |
610 | 670 |
|
611 | 671 | fn mk_model() -> Model { |
@@ -716,6 +776,104 @@ mod tests { |
716 | 776 | assert_eq!(classes[0].source_curator.as_deref(), Some("odoo")); |
717 | 777 | } |
718 | 778 |
|
| 779 | + /// An Odoo-shape model: schema lives entirely in `Model::fields` |
| 780 | + /// (the AR-DSL vectors are empty, as `ruff_python_spo` produces). |
| 781 | + fn mk_odoo_model() -> Model { |
| 782 | + let mut m = Model::new("account_move"); |
| 783 | + m.fields.push(Field { |
| 784 | + name: "name".to_string(), |
| 785 | + ..Default::default() |
| 786 | + }); |
| 787 | + m.fields.push(Field { |
| 788 | + name: "partner_id".to_string(), |
| 789 | + target: Some("res.partner".to_string()), |
| 790 | + relation_kind: Some("many2one".to_string()), |
| 791 | + ..Default::default() |
| 792 | + }); |
| 793 | + m.fields.push(Field { |
| 794 | + name: "line_ids".to_string(), |
| 795 | + target: Some("account.move.line".to_string()), |
| 796 | + inverse_name: Some("move_id".to_string()), |
| 797 | + relation_kind: Some("one2many".to_string()), |
| 798 | + ..Default::default() |
| 799 | + }); |
| 800 | + m.fields.push(Field { |
| 801 | + name: "tag_ids".to_string(), |
| 802 | + target: Some("account.analytic.tag".to_string()), |
| 803 | + relation_kind: Some("many2many".to_string()), |
| 804 | + ..Default::default() |
| 805 | + }); |
| 806 | + m.fields.push(Field { |
| 807 | + name: "amount_total".to_string(), |
| 808 | + emitted_by: Some("_compute_amount".to_string()), |
| 809 | + depends_on: vec!["line_ids.balance".to_string()], |
| 810 | + ..Default::default() |
| 811 | + }); |
| 812 | + m |
| 813 | + } |
| 814 | + |
| 815 | + #[test] |
| 816 | + fn lift_model_python_projects_odoo_fields() { |
| 817 | + // Codex P1 (#131): the Python lift must project `Model::fields` or the |
| 818 | + // class loses its whole schema. Scalar → attribute, relational → |
| 819 | + // association (kind from relation_kind), compute → computed_field. |
| 820 | + let class = lift_model_python(&mk_odoo_model()); |
| 821 | + |
| 822 | + // Scalar + computed fields surface as attributes (named columns). |
| 823 | + let attr_names: Vec<&str> = class.attributes.iter().map(|a| a.name.as_str()).collect(); |
| 824 | + assert!(attr_names.contains(&"name")); |
| 825 | + assert!(attr_names.contains(&"amount_total")); |
| 826 | + // Relational fields are associations, not attributes. |
| 827 | + assert!(!attr_names.contains(&"partner_id")); |
| 828 | + assert!(!attr_names.contains(&"line_ids")); |
| 829 | + |
| 830 | + // relation_kind drives the AssociationKind; comodel → class_name. |
| 831 | + let partner = class |
| 832 | + .associations |
| 833 | + .iter() |
| 834 | + .find(|a| a.name == "partner_id") |
| 835 | + .expect("partner_id association"); |
| 836 | + assert_eq!(partner.kind, AssociationKind::BelongsTo); |
| 837 | + assert_eq!(partner.class_name.as_deref(), Some("res.partner")); |
| 838 | + |
| 839 | + let lines = class |
| 840 | + .associations |
| 841 | + .iter() |
| 842 | + .find(|a| a.name == "line_ids") |
| 843 | + .expect("line_ids association"); |
| 844 | + assert_eq!(lines.kind, AssociationKind::HasMany); |
| 845 | + assert_eq!(lines.class_name.as_deref(), Some("account.move.line")); |
| 846 | + assert_eq!(lines.inverse_of.as_deref(), Some("move_id")); |
| 847 | + |
| 848 | + // The case relation_kind exists to disambiguate: a comodel-only, |
| 849 | + // inverse-less field is a Many2many, NOT a Many2one. |
| 850 | + let tags = class |
| 851 | + .associations |
| 852 | + .iter() |
| 853 | + .find(|a| a.name == "tag_ids") |
| 854 | + .expect("tag_ids association"); |
| 855 | + assert_eq!(tags.kind, AssociationKind::HasAndBelongsToMany); |
| 856 | + assert_eq!(tags.class_name.as_deref(), Some("account.analytic.tag")); |
| 857 | + |
| 858 | + // Compute field → computed_field carrying method + @api.depends. |
| 859 | + assert_eq!(class.computed_fields.len(), 1); |
| 860 | + let computed = &class.computed_fields[0]; |
| 861 | + assert_eq!(computed.field, "amount_total"); |
| 862 | + assert_eq!(computed.compute_method, "_compute_amount"); |
| 863 | + assert_eq!(computed.depends, vec!["line_ids.balance".to_string()]); |
| 864 | + } |
| 865 | + |
| 866 | + #[test] |
| 867 | + fn lift_model_ruby_does_not_project_fields() { |
| 868 | + // The Rails lift reads the AR-DSL vectors, never `Model::fields` |
| 869 | + // (Rails also populates `fields`, so projecting them would |
| 870 | + // double-count). An Odoo-shape model lifted as Ruby stays empty. |
| 871 | + let class = lift_model(&mk_odoo_model()); |
| 872 | + assert!(class.attributes.is_empty()); |
| 873 | + assert!(class.associations.is_empty()); |
| 874 | + assert!(class.computed_fields.is_empty()); |
| 875 | + } |
| 876 | + |
719 | 877 | #[test] |
720 | 878 | fn lift_inheritance_concrete_from_sti_parent() { |
721 | 879 | // mk_model's StiInfo has inherits_from = Some("Issue"). |
|
0 commit comments