Files
goodie/pkg/orm/compute.go
Marc eb92a2e239 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>
2026-04-02 19:57:04 +02:00

273 lines
6.7 KiB
Go

package orm
import "fmt"
// ComputeFunc is a function that computes field values for a recordset.
// Mirrors: @api.depends decorated methods in Odoo.
//
// The function receives a singleton recordset and must return a Values map
// with the computed field values.
//
// Example:
//
// func computeAmount(rs *Recordset) (Values, error) {
// total := 0.0
// // ... sum line amounts ...
// return Values{"amount_total": total}, nil
// }
type ComputeFunc func(rs *Recordset) (Values, error)
// RegisterCompute registers a compute function for a field.
// The same function can compute multiple fields (call RegisterCompute for each).
func (m *Model) RegisterCompute(fieldName string, fn ComputeFunc) {
if m.computes == nil {
m.computes = make(map[string]ComputeFunc)
}
m.computes[fieldName] = fn
}
// SetupComputes builds the reverse dependency map for this model.
// Called after all modules are loaded.
//
// For each field with Depends, creates a mapping:
//
// trigger_field → []computed_field_names
//
// So when trigger_field is written, we know which computes to re-run.
func (m *Model) SetupComputes() {
m.dependencyMap = make(map[string][]string)
for _, name := range m.fieldOrder {
f := m.fields[name]
if f.Compute == "" || len(f.Depends) == 0 {
continue
}
for _, dep := range f.Depends {
m.dependencyMap[dep] = append(m.dependencyMap[dep], name)
}
}
}
// RunStoredComputes runs compute functions for stored computed fields
// and merges results into vals. Called before INSERT in Create().
func RunStoredComputes(m *Model, env *Environment, id int64, vals Values) error {
if len(m.computes) == 0 {
return nil
}
// Collect stored computed fields that have registered functions
seen := make(map[string]bool) // Track compute functions already called (by field name)
for _, name := range m.fieldOrder {
f := m.fields[name]
if f.Compute == "" || !f.Store {
continue
}
fn, ok := m.computes[name]
if !ok {
continue
}
// Deduplicate: same function may compute multiple fields
if seen[f.Compute] {
continue
}
seen[f.Compute] = true
// Create a temporary recordset for the computation
rs := &Recordset{env: env, model: m, ids: []int64{id}}
computed, err := fn(rs)
if err != nil {
return fmt.Errorf("orm: compute %s.%s: %w", m.name, name, err)
}
// Merge computed values
for k, v := range computed {
cf := m.GetField(k)
if cf != nil && cf.Store && cf.Compute != "" {
vals[k] = v
}
}
}
return nil
}
// TriggerRecompute re-computes stored fields that depend on written fields.
// Called after UPDATE in Write().
func TriggerRecompute(rs *Recordset, writtenFields Values) error {
m := rs.model
if len(m.dependencyMap) == 0 || len(m.computes) == 0 {
return nil
}
// Find which computed fields need re-computation
toRecompute := make(map[string]bool)
for fieldName := range writtenFields {
for _, computedField := range m.dependencyMap[fieldName] {
toRecompute[computedField] = true
}
}
if len(toRecompute) == 0 {
return nil
}
// Run computes for each record
seen := make(map[string]bool)
for _, id := range rs.IDs() {
singleton := rs.Browse(id)
recomputedVals := make(Values)
for fieldName := range toRecompute {
f := m.GetField(fieldName)
if f == nil || !f.Store {
continue
}
fn, ok := m.computes[fieldName]
if !ok {
continue
}
if seen[f.Compute] {
continue
}
seen[f.Compute] = true
computed, err := fn(singleton)
if err != nil {
return fmt.Errorf("orm: recompute %s.%s: %w", m.name, fieldName, err)
}
for k, v := range computed {
cf := m.GetField(k)
if cf != nil && cf.Store && cf.Compute != "" {
recomputedVals[k] = v
}
}
}
// Write recomputed values directly to DB (bypass hooks to avoid infinite loop)
if len(recomputedVals) > 0 {
if err := writeDirectNohook(rs.env, m, id, recomputedVals); err != nil {
return err
}
}
// Reset seen for next record
seen = make(map[string]bool)
}
return nil
}
// writeDirectNohook writes values directly without triggering hooks or recomputes.
func writeDirectNohook(env *Environment, m *Model, id int64, vals Values) error {
var setClauses []string
var args []interface{}
idx := 1
for k, v := range vals {
f := m.GetField(k)
if f == nil || !f.IsStored() {
continue
}
setClauses = append(setClauses, fmt.Sprintf("%q = $%d", f.Column(), idx))
args = append(args, v)
idx++
}
if len(setClauses) == 0 {
return nil
}
args = append(args, id)
query := fmt.Sprintf(
`UPDATE %q SET %s WHERE "id" = $%d`,
m.Table(), joinStrings(setClauses, ", "), idx,
)
_, err := env.tx.Exec(env.ctx, query, args...)
return err
}
func joinStrings(s []string, sep string) string {
result := ""
for i, str := range s {
if i > 0 {
result += sep
}
result += str
}
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() {
m.SetupComputes()
}
}