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

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