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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user