Skip to content

Commit 984f787

Browse files
committed
feat(ogar-from-ruff): project Odoo Model::fields into Class schema (Codex P1 #131)
The Python lift previously only set the language discriminant; it read the Rails-side AR-DSL vectors (model.attributes / model.associations / …), which are empty for an Odoo model. An Odoo model carries its schema in the core-7 `Model::fields` vector, so the lifted class came out with empty attributes / associations / computed_fields — downstream consumers got a schema-less class (Codex P1 on #131). Add a Python-only `project_odoo_fields` pass mapping `Model::fields` onto the existing Class columns: - relational field (target set) -> Association; kind from `relation_kind` (many2one->BelongsTo, one2many->HasMany, many2many->HasAndBelongsToMany), class_name = raw comodel, inverse_of = One2many inverse. - non-relational field -> Attribute. - compute field (emitted_by set) -> ComputedField (method + @api.depends), in addition to its Attribute/Association. Gated on Language::Python: Rails ALSO populates Model::fields (DB columns), so projecting them for Rails would double-count its AR-DSL surface. Adds `ComputedField::new(field, compute_method)` to ogar-vocab (it lacked a constructor; #[non_exhaustive] makes a struct literal non-constructible cross-crate — parallels Association::new / Attribute::new). Consumes ruff's `relation_kind` predicate (AdaWorldAPI/ruff#35): target + inverse_name alone can't separate a Many2one from a Many2many (both comodel-only, no inverse), so the kind is required to pick the right AssociationKind. Tests: lift_model_python_projects_odoo_fields (scalar/relational/compute, incl. the M2O-vs-M2M case) + lift_model_ruby_does_not_project_fields (Ruby gating). Verified via probe (local ruff w/ relation_kind + real ogar-vocab): 30 tests pass, clippy -D warnings clean. NOTE: not yet pushed — gated on ruff#35 landing on ruff main + an OGAR Cargo.lock ruff-commit bump. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent a7495af commit 984f787

2 files changed

Lines changed: 175 additions & 3 deletions

File tree

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

Lines changed: 161 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666

6767
use ogar_vocab::{
6868
canonical_concept, ActionDef, Association, AssociationKind, Attribute, Callback, Class,
69-
EnumDecl, EnumSource, Inheritance, Language, Scope, Validation,
69+
ComputedField, EnumDecl, EnumSource, Inheritance, Language, Scope, Validation,
7070
};
7171
use ruff_spo_triplet::{
7272
AssocDecl, AssocKind, AttrDecl, AttrKind, Callback as RuffCallback, ConcernKind, Model,
@@ -181,9 +181,69 @@ fn lift_model_with_language(model: &Model, language: Language) -> Class {
181181
class.callbacks = model.callbacks.iter().map(lift_callback).collect();
182182
class.validations = model.validations.iter().filter_map(lift_validation).collect();
183183
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+
}
184191
class
185192
}
186193

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+
187247
// ───────────────────────── ProjectWorkItem role projection ─────────────
188248
//
189249
// Curator-side mapping: project_work_item's canonical roles vs the
@@ -604,8 +664,8 @@ fn parse_bool(s: &str) -> Option<bool> {
604664
mod tests {
605665
use super::*;
606666
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,
609669
};
610670

611671
fn mk_model() -> Model {
@@ -716,6 +776,104 @@ mod tests {
716776
assert_eq!(classes[0].source_curator.as_deref(), Some("odoo"));
717777
}
718778

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+
719877
#[test]
720878
fn lift_inheritance_concrete_from_sti_parent() {
721879
// mk_model's StiInfo has inherits_from = Some("Issue").

crates/ogar-vocab/src/lib.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1978,6 +1978,20 @@ impl Attribute {
19781978
}
19791979
}
19801980

1981+
impl ComputedField {
1982+
/// Build a new computed-field declaration with the field name and its
1983+
/// compute method set. Remaining metadata (`depends`, `stored`,
1984+
/// `inverse_method`, …) is filled in by the caller.
1985+
#[must_use]
1986+
pub fn new(field: impl Into<String>, compute_method: impl Into<String>) -> Self {
1987+
Self {
1988+
field: field.into(),
1989+
compute_method: compute_method.into(),
1990+
..Default::default()
1991+
}
1992+
}
1993+
}
1994+
19811995
impl Scope {
19821996
/// Build a new scope with name and body source.
19831997
#[must_use]

0 commit comments

Comments
 (0)