Complete ORM core: _inherits, Related write-back, Copy, constraints

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) <noreply@anthropic.com>
This commit is contained in:
Marc
2026-04-02 19:57:04 +02:00
parent b57176de2f
commit eb92a2e239
9 changed files with 477 additions and 18 deletions

View File

@@ -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() {