From 2e5a550069b111b879c52e1627becdcebc8680a7 Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 3 Apr 2026 13:23:40 +0200 Subject: [PATCH] Add _inherit (ExtendModel) + Inverse fields + sale extends partner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ORM: - ExtendModel(name) retrieves existing model for extension (mirrors Python _inherit without _name). Panics on missing model. - RegisterInverse(fieldName, fn) convenience for computed write-back - Inverse field handling in Write(): caches new value, calls inverse method so computed fields can be written back Sale module: - Extends res.partner with sale_order_ids (O2M) and sale_order_count (computed) via ExtendModel — demonstrates real _inherit pattern Co-Authored-By: Claude Opus 4.6 (1M context) --- addons/sale/models/init.go | 35 +++++++++++++++++++++++++++++++++++ pkg/orm/model.go | 33 +++++++++++++++++++++++++++++++++ pkg/orm/recordset.go | 25 +++++++++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/addons/sale/models/init.go b/addons/sale/models/init.go index e6b9381..925444a 100644 --- a/addons/sale/models/init.go +++ b/addons/sale/models/init.go @@ -1,6 +1,41 @@ package models +import "odoo-go/pkg/orm" + func Init() { initSaleOrder() initSaleOrderLine() + initResPartnerSaleExtension() +} + +// initResPartnerSaleExtension extends res.partner with sale-specific fields. +// Mirrors: odoo/addons/sale/models/res_partner.py +// +// class ResPartner(models.Model): +// _inherit = 'res.partner' +// sale_order_count = fields.Integer(compute='_compute_sale_order_count') +// sale_order_ids = fields.One2many('sale.order', 'partner_id', string='Sales Orders') +func initResPartnerSaleExtension() { + partner := orm.ExtendModel("res.partner") + partner.AddFields( + orm.One2many("sale_order_ids", "sale.order", "partner_id", orm.FieldOpts{ + String: "Sales Orders", + }), + orm.Integer("sale_order_count", orm.FieldOpts{ + String: "Sale Order Count", + Compute: "_compute_sale_order_count", + }), + ) + partner.RegisterCompute("sale_order_count", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + partnerID := rs.IDs()[0] + var count int + err := env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) FROM sale_order WHERE partner_id = $1`, partnerID, + ).Scan(&count) + if err != nil { + count = 0 + } + return orm.Values{"sale_order_count": count}, nil + }) } diff --git a/pkg/orm/model.go b/pkg/orm/model.go index b151714..d86cfeb 100644 --- a/pkg/orm/model.go +++ b/pkg/orm/model.go @@ -276,6 +276,39 @@ func (m *Model) Extend(fields ...*Field) *Model { return m } +// ExtendModel retrieves an existing model for extension by another module. +// Mirrors: Python Odoo's _inherit = 'model.name' (without _name). +// +// This is the formal mechanism for cross-module model extension. It provides: +// 1. Explicit intent (extending vs creating) +// 2. Panic on missing model (catch registration order bugs early) +// 3. Python Odoo compatibility pattern +// +// Usage: +// +// m := orm.ExtendModel("res.partner") +// m.AddFields(orm.Char("industry", orm.FieldOpts{String: "Industry"})) +// m.RegisterMethod("action_custom", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { ... }) +func ExtendModel(name string) *Model { + m := Registry.Get(name) + if m == nil { + panic(fmt.Sprintf("orm: cannot extend unregistered model %q", name)) + } + return m +} + +// RegisterInverse registers an inverse method for a computed field. +// The method is auto-named "_inverse_" and linked to the field's +// Inverse property so that Write() calls it automatically. +// Mirrors: odoo/orm/fields.py Field.inverse +func (m *Model) RegisterInverse(fieldName string, fn MethodFunc) { + inverseName := "_inverse_" + fieldName + m.RegisterMethod(inverseName, fn) + if f := m.GetField(fieldName); f != nil { + f.Inverse = inverseName + } +} + // CreateTableSQL generates the CREATE TABLE statement for this model. // Mirrors: odoo/orm/models.py BaseModel._table_exist / init() func (m *Model) CreateTableSQL() string { diff --git a/pkg/orm/recordset.go b/pkg/orm/recordset.go index 5e49745..fe5e3f2 100644 --- a/pkg/orm/recordset.go +++ b/pkg/orm/recordset.go @@ -393,6 +393,31 @@ func (rs *Recordset) Write(vals Values) error { } } + // Handle inverse fields (write-back for computed fields). + // Mirrors: odoo/orm/fields.py Field.inverse + // + // When a computed field with an Inverse method is written to, the inverse + // method is called so it can propagate the value to the underlying fields. + // In Python Odoo, the inverse method reads self.field_name from cache (which + // has the new value). We replicate this by caching the new value before calling. + for fieldName := range vals { + f := m.GetField(fieldName) + if f == nil || f.Inverse == "" { + continue + } + inverseMethod, ok := m.Methods[f.Inverse] + if !ok { + continue + } + // Cache the new value so the inverse method can read it via Get() + for _, id := range rs.ids { + rs.env.cache.Set(m.Name(), id, fieldName, vals[fieldName]) + } + if _, err := inverseMethod(rs); err != nil { + return fmt.Errorf("orm: inverse %s.%s: %w", m.name, f.Inverse, err) + } + } + // Trigger recompute for stored computed fields that depend on written fields if err := TriggerRecompute(rs, vals); err != nil { return err