From eb92a2e239ec260b321e7a50ae689e89c5c4d9aa Mon Sep 17 00:00:00 2001 From: Marc Date: Thu, 2 Apr 2026 19:57:04 +0200 Subject: [PATCH] Complete ORM core: _inherits, Related write-back, Copy, constraints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch 1 (quick-fixes): - Field.Copy attribute + IsCopyable() method for copy control - Constraints now run in Write() (was only Create — bug fix) - Readonly fields silently skipped in Write() - active_test: auto-filter archived records in Search() Batch 2 (Related field write-back): - preprocessRelatedWrites() follows FK chain and writes to target model - Enables Settings page to edit company name/address/etc. - Loop protection via _write_related_depth context counter Batch 3 (_inherits delegation): - SetupAllInherits() generates Related fields from parent models - preprocessInheritsCreate() auto-creates parent records on Create - Declared on res.users, res.company, product.product - Called in LoadModules before compute setup Batch 4 (Copy method): - Recordset.Copy(defaults) with blacklist, IsCopyable check - M2M re-linking, rec_name "(copy)" suffix - Replaces simplified copy case in server dispatch Batch 5 (Onchange compute): - RunOnchangeComputes() triggers dependent computes on field change - Virtual record (ID=-1) with client values in cache - Integrated into onchange RPC handler Co-Authored-By: Claude Opus 4.6 (1M context) --- addons/base/models/res_company.go | 1 + addons/base/models/res_users.go | 1 + addons/product/models/product.go | 1 + pkg/modules/graph.go | 5 +- pkg/orm/compute.go | 68 ++++++++++ pkg/orm/field.go | 26 ++++ pkg/orm/inherits.go | 152 +++++++++++++++++++++ pkg/orm/recordset.go | 214 ++++++++++++++++++++++++++++++ pkg/server/server.go | 27 ++-- 9 files changed, 477 insertions(+), 18 deletions(-) create mode 100644 pkg/orm/inherits.go diff --git a/addons/base/models/res_company.go b/addons/base/models/res_company.go index 42e5342..e026834 100644 --- a/addons/base/models/res_company.go +++ b/addons/base/models/res_company.go @@ -11,6 +11,7 @@ func initResCompany() { m := orm.NewModel("res.company", orm.ModelOpts{ Description: "Companies", Order: "sequence, name", + Inherits: map[string]string{"res.partner": "partner_id"}, }) // -- Identity -- diff --git a/addons/base/models/res_users.go b/addons/base/models/res_users.go index 4444c64..16387b8 100644 --- a/addons/base/models/res_users.go +++ b/addons/base/models/res_users.go @@ -16,6 +16,7 @@ func initResUsers() { m := orm.NewModel("res.users", orm.ModelOpts{ Description: "Users", Order: "login", + Inherits: map[string]string{"res.partner": "partner_id"}, }) // -- Authentication -- diff --git a/addons/product/models/product.go b/addons/product/models/product.go index 07f5844..6da96f9 100644 --- a/addons/product/models/product.go +++ b/addons/product/models/product.go @@ -131,6 +131,7 @@ func initProductProduct() { m := orm.NewModel("product.product", orm.ModelOpts{ Description: "Product", Order: "default_code, name, id", + Inherits: map[string]string{"product.template": "product_tmpl_id"}, }) m.AddFields( diff --git a/pkg/modules/graph.go b/pkg/modules/graph.go index 2d8bc70..7cde2d8 100644 --- a/pkg/modules/graph.go +++ b/pkg/modules/graph.go @@ -105,7 +105,10 @@ func LoadModules(moduleNames []string) error { } } - // Phase 3: Build computed field dependency maps + // Phase 3: Generate Related fields for _inherits delegation + orm.SetupAllInherits() + + // Phase 4: Build computed field dependency maps orm.SetupAllComputes() return nil diff --git a/pkg/orm/compute.go b/pkg/orm/compute.go index 4cb61e8..09d116c 100644 --- a/pkg/orm/compute.go +++ b/pkg/orm/compute.go @@ -196,6 +196,74 @@ func joinStrings(s []string, sep string) string { return result } +// RunOnchangeComputes runs computed fields that depend on changed fields. +// Used by the onchange RPC to recompute values when form fields change. +// Mirrors: odoo/orm/models.py BaseModel._onchange_eval() +// +// Creates a virtual record (ID=-1) with the client's values in cache, +// then runs any compute functions whose dependencies include the changed fields. +func RunOnchangeComputes(m *Model, env *Environment, currentVals Values, changedFields []string) Values { + result := make(Values) + if m == nil || len(m.dependencyMap) == 0 { + return result + } + + // Find which computed fields need recomputation + toRecompute := make(map[string]bool) + for _, fname := range changedFields { + if deps, ok := m.dependencyMap[fname]; ok { + for _, computedField := range deps { + toRecompute[computedField] = true + } + } + } + + if len(toRecompute) == 0 { + return result + } + + // Populate cache with current form values so computes can read them + fakeID := int64(-1) + for k, v := range currentVals { + env.cache.Set(m.Name(), fakeID, k, v) + } + defer env.cache.InvalidateRecord(m.Name(), fakeID) + + // Create virtual recordset + rs := &Recordset{env: env, model: m, ids: []int64{fakeID}} + + // Track which compute functions we've already called (multiple fields can share one function) + seen := make(map[string]bool) + + for fieldName := range toRecompute { + f := m.GetField(fieldName) + if f == nil || f.Compute == "" { + continue + } + // Don't call the same compute function twice + if seen[f.Compute] { + continue + } + seen[f.Compute] = true + + fn, ok := m.computes[fieldName] + if !ok { + continue + } + + computed, err := fn(rs) + if err != nil { + // Non-fatal: skip failed computes during onchange + continue + } + for k, v := range computed { + result[k] = v + } + } + + return result +} + // SetupAllComputes calls SetupComputes on all registered models. func SetupAllComputes() { for _, m := range Registry.Models() { diff --git a/pkg/orm/field.go b/pkg/orm/field.go index 80543d7..014db8d 100644 --- a/pkg/orm/field.go +++ b/pkg/orm/field.go @@ -60,6 +60,9 @@ type Field struct { // Translation Translate bool // Field supports multi-language + // Copy + Copy bool // Whether this field is copied on record duplication + // Groups (access control) Groups string // Comma-separated group XML IDs @@ -97,6 +100,27 @@ func (f *Field) IsStored() bool { return f.Type.IsStored() } +// IsCopyable returns true if this field should be copied on record duplication. +// Mirrors: odoo/orm/fields.py Field.copy +func (f *Field) IsCopyable() bool { + // If explicitly set, use that + if f.Copy { + return true + } + // Defaults: non-copyable for computed, O2M, id, timestamps + if f.Name == "id" || f.Name == "create_uid" || f.Name == "create_date" || + f.Name == "write_uid" || f.Name == "write_date" || f.Name == "password" { + return false + } + if f.Compute != "" && !f.Store { + return false + } + if f.Type == TypeOne2many { + return false + } + return true +} + // IsRelational returns true for relational field types. func (f *Field) IsRelational() bool { return f.Type.IsRelational() @@ -130,6 +154,7 @@ type FieldOpts struct { OnDelete OnDelete CurrencyField string Translate bool + Copy bool Groups string } @@ -160,6 +185,7 @@ func newField(name string, typ FieldType, opts FieldOpts) *Field { OnDelete: opts.OnDelete, CurrencyField: opts.CurrencyField, Translate: opts.Translate, + Copy: opts.Copy, Groups: opts.Groups, column: name, } diff --git a/pkg/orm/inherits.go b/pkg/orm/inherits.go new file mode 100644 index 0000000..f89dfa5 --- /dev/null +++ b/pkg/orm/inherits.go @@ -0,0 +1,152 @@ +package orm + +import ( + "fmt" + "log" +) + +// SetupAllInherits generates Related fields for all models with _inherits declarations. +// Must be called after all models are registered but before computes are set up. +// Mirrors: odoo/orm/model_classes.py _add_inherited_fields() +func SetupAllInherits() { + for _, m := range Registry.Models() { + if len(m.inherits) == 0 { + continue + } + setupModelInherits(m) + } +} + +func setupModelInherits(m *Model) { + for parentName, fkField := range m.inherits { + parent := Registry.Get(parentName) + if parent == nil { + log.Printf("inherits: parent model %q not found for %s", parentName, m.Name()) + continue + } + + // Verify FK field exists on child + fkDef := m.GetField(fkField) + if fkDef == nil { + log.Printf("inherits: FK field %q not found on %s", fkField, m.Name()) + continue + } + + // For each parent field, create a Related field on child if not already defined + inherited := 0 + for _, fname := range parent.fieldOrder { + // Skip magic fields (child has its own) + if fname == "id" || fname == "display_name" || + fname == "create_uid" || fname == "create_date" || + fname == "write_uid" || fname == "write_date" { + continue + } + + // Don't override fields already defined on child + if existing := m.GetField(fname); existing != nil { + continue + } + + pf := parent.GetField(fname) + if pf == nil { + continue + } + + // Create Related field: "fk_field.field_name" + relatedPath := fkField + "." + fname + + inheritedField := &Field{ + Name: fname, + Type: pf.Type, + String: pf.String, + Help: pf.Help, + Related: relatedPath, + Comodel: pf.Comodel, + InverseField: pf.InverseField, + Selection: pf.Selection, + CurrencyField: pf.CurrencyField, + Readonly: pf.Readonly, + Required: false, // Inherited fields are not required on child + model: m, + column: fname, + } + + // Add to model's field maps + m.fields[fname] = inheritedField + m.allFields[fname] = inheritedField + m.fieldOrder = append(m.fieldOrder, fname) + inherited++ + } + + log.Printf("inherits: %s inherits %d fields from %s via %s", + m.Name(), inherited, parentName, fkField) + } +} + +// preprocessInheritsCreate separates inherited field values and creates/updates parent records. +// Must be called in Create() BEFORE ApplyDefaults. +// Mirrors: odoo/orm/models.py create() _inherits handling +func preprocessInheritsCreate(env *Environment, m *Model, vals Values) error { + if len(m.inherits) == 0 { + return nil + } + + for parentName, fkField := range m.inherits { + parent := Registry.Get(parentName) + if parent == nil { + continue + } + + // Separate parent vals from child vals + parentVals := make(Values) + for fieldName, val := range vals { + // Check if this field belongs to parent (not child's own stored field) + childField := m.fields[fieldName] + parentField := parent.GetField(fieldName) + + if parentField != nil && fieldName != fkField && + (childField == nil || childField.Related != "") { + parentVals[fieldName] = val + delete(vals, fieldName) + } + } + + // Check if FK is already provided + fkVal, hasFk := vals[fkField] + if hasFk && fkVal != nil { + // FK provided — update existing parent record with any inherited vals + fkID := int64(0) + switch v := fkVal.(type) { + case float64: + fkID = int64(v) + case int64: + fkID = v + case int32: + fkID = int64(v) + } + if fkID > 0 && len(parentVals) > 0 { + parentRS := env.Model(parentName).Browse(fkID) + if err := parentRS.Write(parentVals); err != nil { + return fmt.Errorf("inherits: update %s(%d): %w", parentName, fkID, err) + } + } + } else { + // No FK — create parent record first + if len(parentVals) == 0 { + // Need at least something for the parent (e.g., name) + // Check if name is in child vals but not moved to parent + if nameVal, ok := vals["name"]; ok { + parentVals["name"] = nameVal + } + } + parentRS := env.Model(parentName) + created, err := parentRS.Create(parentVals) + if err != nil { + return fmt.Errorf("inherits: create %s: %w", parentName, err) + } + vals[fkField] = created.ID() + } + } + + return nil +} diff --git a/pkg/orm/recordset.go b/pkg/orm/recordset.go index a7a8b87..ca1155f 100644 --- a/pkg/orm/recordset.go +++ b/pkg/orm/recordset.go @@ -94,6 +94,11 @@ func (rs *Recordset) WithContext(ctx map[string]interface{}) *Recordset { func (rs *Recordset) Create(vals Values) (*Recordset, error) { m := rs.model + // Handle _inherits: separate parent vals and create/update parent records + if err := preprocessInheritsCreate(rs.env, m, vals); err != nil { + return nil, err + } + // Phase 1: Apply defaults for missing fields ApplyDefaults(m, vals) @@ -211,6 +216,96 @@ func (rs *Recordset) Create(vals Values) (*Recordset, error) { return result, nil } +// preprocessRelatedWrites handles write-back for Related fields. +// When writing to a Related field (e.g., "company_id.name"), this function +// follows the FK chain and writes to the target model instead. +// Mirrors: odoo/orm/fields.py _inverse_related() +func preprocessRelatedWrites(env *Environment, m *Model, ids []int64, vals Values) error { + // Prevent infinite recursion + depth := 0 + if d, ok := env.context["_write_related_depth"]; ok { + if di, ok := d.(int); ok { + depth = di + } + } + if depth > 5 { + return nil // Safety: max recursion depth + } + + var relatedFields []string + for fieldName := range vals { + f := m.GetField(fieldName) + if f != nil && f.Related != "" && !f.IsStored() { + relatedFields = append(relatedFields, fieldName) + } + } + + if len(relatedFields) == 0 { + return nil + } + + // Read FK values for the records being written + // We need the FK IDs to know which parent records to update + for _, fieldName := range relatedFields { + f := m.GetField(fieldName) + parts := strings.Split(f.Related, ".") + if len(parts) != 2 { + delete(vals, fieldName) // Can't handle multi-hop yet, skip + continue + } + fkFieldName := parts[0] + targetFieldName := parts[1] + + fkDef := m.GetField(fkFieldName) + if fkDef == nil || fkDef.Type != TypeMany2one || fkDef.Comodel == "" { + delete(vals, fieldName) + continue + } + + value := vals[fieldName] + delete(vals, fieldName) // Remove from vals — no local column + + // Read FK IDs for all records + var fkIDs []int64 + for _, id := range ids { + var fkID *int64 + env.tx.QueryRow(env.ctx, + fmt.Sprintf(`SELECT %q FROM %q WHERE id = $1`, fkDef.Column(), m.Table()), + id, + ).Scan(&fkID) + if fkID != nil && *fkID > 0 { + fkIDs = append(fkIDs, *fkID) + } + } + + if len(fkIDs) == 0 { + continue + } + + // Deduplicate + seen := make(map[int64]bool) + var uniqueIDs []int64 + for _, id := range fkIDs { + if !seen[id] { + seen[id] = true + uniqueIDs = append(uniqueIDs, id) + } + } + + // Write to target model with increased depth + depthEnv := env.WithContext(map[string]interface{}{ + "_write_related_depth": depth + 1, + }) + targetRS := &Recordset{env: depthEnv, model: Registry.MustGet(fkDef.Comodel), ids: uniqueIDs} + if err := targetRS.Write(Values{targetFieldName: value}); err != nil { + return fmt.Errorf("related write-back %s.%s → %s.%s: %w", + m.Name(), fieldName, fkDef.Comodel, targetFieldName, err) + } + } + + return nil +} + // Write updates the records in this recordset. // Mirrors: records.write(vals) func (rs *Recordset) Write(vals Values) error { @@ -230,11 +325,20 @@ func (rs *Recordset) Write(vals Values) error { } vals["write_date"] = "NOW()" // Will be handled specially + // Handle Related field write-back (must happen before building SET clauses) + if err := preprocessRelatedWrites(rs.env, m, rs.ids, vals); err != nil { + return err + } + for _, name := range m.fieldOrder { f := m.fields[name] if name == "id" || !f.IsStored() { continue } + // Skip readonly fields (like Python Odoo, silently ignore) + if f.Readonly && name != "write_uid" && name != "write_date" { + continue + } val, exists := vals[name] if !exists { continue @@ -294,6 +398,15 @@ func (rs *Recordset) Write(vals Values) error { return err } + // Run constraints after write (mirrors Create constraint check) + for _, constraint := range m.Constraints { + for _, id := range rs.ids { + if err := constraint(rs.Browse(id)); err != nil { + return err + } + } + } + return nil } @@ -590,6 +703,25 @@ func (rs *Recordset) Search(domain Domain, opts ...SearchOpts) (*Recordset, erro m := rs.model opt := mergeSearchOpts(opts) + // Auto-filter archived records unless active_test=false in context + // Mirrors: odoo/orm/models.py BaseModel._where_calc() + if activeField := m.GetField("active"); activeField != nil { + activeTest := true + if v, ok := rs.env.context["active_test"]; ok { + if b, ok := v.(bool); ok { + activeTest = b + } + } + if activeTest { + activeLeaf := Leaf("active", "=", true) + if len(domain) == 0 { + domain = Domain{activeLeaf} + } else { + domain = append(Domain{OpAnd}, append(domain, activeLeaf)...) + } + } + } + // Apply record rules (e.g., multi-company filter) domain = ApplyRecordRules(rs.env, m, domain) @@ -749,6 +881,88 @@ func (rs *Recordset) Records() []*Recordset { return result } +// Copy duplicates a singleton record, returning the new record. +// Mirrors: odoo/orm/models.py BaseModel.copy() +// +// new_partner = partner.Copy(orm.Values{"name": "New Name"}) +func (rs *Recordset) Copy(defaults Values) (*Recordset, error) { + if err := rs.Ensure(); err != nil { + return nil, err + } + m := rs.model + + // Read all stored fields + records, err := rs.Read(nil) + if err != nil || len(records) == 0 { + return nil, fmt.Errorf("orm: copy: record %s(%d) not found", m.name, rs.ids[0]) + } + original := records[0] + + // Blacklisted fields (never copied) + blacklist := map[string]bool{ + "id": true, "create_uid": true, "create_date": true, + "write_uid": true, "write_date": true, "display_name": true, + "password": true, "password_crypt": true, + } + + // Also blacklist _inherits FK fields (parent will be copied separately) + for _, fkField := range m.inherits { + blacklist[fkField] = true + } + + newVals := make(Values) + + for _, name := range m.fieldOrder { + if blacklist[name] { + continue + } + f := m.fields[name] + if f == nil || !f.IsCopyable() || !f.IsStored() { + continue + } + + val, exists := original[name] + if !exists || val == nil { + continue + } + + switch f.Type { + case TypeMany2many: + // Re-link existing M2M entries (don't duplicate targets) + if ids, ok := val.([]int64); ok && len(ids) > 0 { + cmds := make([]interface{}, len(ids)) + for i, id := range ids { + cmds[i] = []interface{}{4, id, 0} // Command LINK + } + newVals[name] = cmds + } + case TypeOne2many: + // Skip — O2M records are NOT copied by default (copy=false) + continue + default: + newVals[name] = val + } + } + + // Append "(copy)" to rec_name field + recName := m.recName + if recName == "" { + recName = "name" + } + if nameVal, ok := newVals[recName].(string); ok { + newVals[recName] = nameVal + " (copy)" + } + + // Apply user-provided defaults (override copied values) + if defaults != nil { + for k, v := range defaults { + newVals[k] = v + } + } + + return rs.Create(newVals) +} + // Union returns the union of this recordset with others. // Mirrors: records | other_records func (rs *Recordset) Union(others ...*Recordset) *Recordset { diff --git a/pkg/server/server.go b/pkg/server/server.go index 02f3355..e998d49 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -429,8 +429,10 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa if len(params.Args) >= 3 { if vals, ok := params.Args[1].(map[string]interface{}); ok { if fieldNames, ok := params.Args[2].([]interface{}); ok { + var changed []string for _, fn := range fieldNames { fname, _ := fn.(string) + changed = append(changed, fname) if handler, exists := model.OnchangeHandlers[fname]; exists { updates := handler(env, vals) for k, v := range updates { @@ -438,6 +440,11 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa } } } + // Run computed fields that depend on changed fields + computed := orm.RunOnchangeComputes(model, env, vals, changed) + for k, v := range computed { + defaults[k] = v + } } } } @@ -660,23 +667,9 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa if len(ids) == 0 { return nil, &RPCError{Code: -32000, Message: "No record to copy"} } - // Read the original record - records, err := rs.Browse(ids[0]).Read(nil) - if err != nil || len(records) == 0 { - return nil, &RPCError{Code: -32000, Message: "Record not found"} - } - // Remove id and unique fields, create copy - vals := records[0] - delete(vals, "id") - delete(vals, "create_uid") - delete(vals, "write_uid") - delete(vals, "create_date") - delete(vals, "write_date") - // Append "(copy)" to name if it exists - if name, ok := vals["name"].(string); ok { - vals["name"] = name + " (copy)" - } - created, err := rs.Create(vals) + // Parse optional default overrides from args[1] + defaults := parseValuesAt(params.Args, 1) + created, err := rs.Browse(ids[0]).Copy(defaults) if err != nil { return nil, &RPCError{Code: -32000, Message: err.Error()} }