diff --git a/pkg/orm/domain.go b/pkg/orm/domain.go index 50d8924..7487db1 100644 --- a/pkg/orm/domain.go +++ b/pkg/orm/domain.go @@ -198,11 +198,10 @@ func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) { case domainGroup: // domainGroup wraps a sub-domain as a single node. // Compile it recursively as a full domain. - subSQL, subParams, err := dc.compileDomainGroup(Domain(n)) + subSQL, _, err := dc.compileDomainGroup(Domain(n)) if err != nil { return "", err } - _ = subParams // params already appended inside compileDomainGroup return subSQL, nil } diff --git a/pkg/orm/relational.go b/pkg/orm/relational.go index ea87e01..2e536e9 100644 --- a/pkg/orm/relational.go +++ b/pkg/orm/relational.go @@ -269,6 +269,9 @@ func ParseCommands(raw interface{}) ([]Command, bool) { return cmds, true } +// ToRecordID extracts an int64 ID from various numeric types. Public alias. +func ToRecordID(v interface{}) (int64, bool) { return toInt64(v) } + func toInt64(v interface{}) (int64, bool) { switch n := v.(type) { case float64: diff --git a/pkg/server/web_methods.go b/pkg/server/web_methods.go index 2e1dc5a..037e30e 100644 --- a/pkg/server/web_methods.go +++ b/pkg/server/web_methods.go @@ -83,17 +83,7 @@ func handleWebSearchRead(env *orm.Environment, model string, params CallKWParams } } - // Format M2O fields as {id, display_name} when spec requests it - formatM2OFields(env, model, records, spec) - - // Fetch O2M fields (child records) based on specification - formatO2MFields(env, model, records, spec) - - // Format date/datetime fields to Odoo's expected string format - formatDateFields(model, records) - - // Convert SQL NULLs to Odoo-expected defaults (false, 0, etc.) - normalizeNullFields(model, records) + formatRecordsForWeb(env, model, records, spec) if records == nil { records = []orm.Values{} @@ -134,16 +124,7 @@ func handleWebRead(env *orm.Environment, model string, params CallKWParams) (int return nil, &RPCError{Code: -32000, Message: err.Error()} } - formatM2OFields(env, model, records, spec) - - // Fetch O2M fields (child records) based on specification - formatO2MFields(env, model, records, spec) - - // Format date/datetime fields to Odoo's expected string format - formatDateFields(model, records) - - // Convert SQL NULLs to Odoo-expected defaults (false, 0, etc.) - normalizeNullFields(model, records) + formatRecordsForWeb(env, model, records, spec) if records == nil { records = []orm.Values{} @@ -151,6 +132,14 @@ func handleWebRead(env *orm.Environment, model string, params CallKWParams) (int return records, nil } +// formatRecordsForWeb applies all web-client formatting in one call. +func formatRecordsForWeb(env *orm.Environment, model string, records []orm.Values, spec map[string]interface{}) { + formatM2OFields(env, model, records, spec) + formatO2MFields(env, model, records, spec) + formatDateFields(model, records) + normalizeNullFields(model, records) +} + // formatO2MFields fetches One2many child records and adds them to the response. // Mirrors: odoo/addons/web/models/models.py web_read() O2M handling func formatO2MFields(env *orm.Environment, modelName string, records []orm.Values, spec map[string]interface{}) { @@ -252,80 +241,75 @@ func formatM2OFields(env *orm.Environment, modelName string, records []orm.Value return } + // Batch M2O resolution: collect all FK IDs per (fieldName, comodel), one NameGet per comodel + type m2oKey struct{ field, comodel string } + idSets := make(map[m2oKey]map[int64]bool) + + for fieldName, fieldSpec := range spec { + f := m.GetField(fieldName) + if f == nil || f.Type != orm.TypeMany2one || f.Comodel == "" { + continue + } + if _, ok := fieldSpec.(map[string]interface{}); !ok { + continue + } + key := m2oKey{fieldName, f.Comodel} + idSets[key] = make(map[int64]bool) + for _, rec := range records { + if fkID, ok := orm.ToRecordID(rec[fieldName]); ok && fkID > 0 { + idSets[key][fkID] = true + } + } + } + + // One NameGet per comodel (batched) + nameCache := make(map[m2oKey]map[int64]string) + for key, ids := range idSets { + if len(ids) == 0 { + continue + } + var idSlice []int64 + for id := range ids { + idSlice = append(idSlice, id) + } + coRS := env.Model(key.comodel).Browse(idSlice...) + names, err := coRS.NameGet() + if err == nil { + nameCache[key] = names + } + } + + // Apply resolved names to records for _, rec := range records { for fieldName, fieldSpec := range spec { f := m.GetField(fieldName) - if f == nil || f.Type != orm.TypeMany2one { + if f == nil || f.Type != orm.TypeMany2one || f.Comodel == "" { continue } - - // Accept any spec entry for M2O fields (even empty {} means include it) - _, ok := fieldSpec.(map[string]interface{}) - if !ok { + if _, ok := fieldSpec.(map[string]interface{}); !ok { continue } - - // Get the raw FK ID - rawID := rec[fieldName] - fkID := int64(0) - switch v := rawID.(type) { - case int64: - fkID = v - case int32: - fkID = int64(v) - case float64: - fkID = int64(v) - } - - if fkID == 0 { - rec[fieldName] = false // Odoo convention for empty M2O + fkID, ok := orm.ToRecordID(rec[fieldName]) + if !ok || fkID == 0 { + rec[fieldName] = false continue } - - // Fetch display_name from comodel — return as [id, "name"] array - if f.Comodel != "" { - coRS := env.Model(f.Comodel).Browse(fkID) - names, err := coRS.NameGet() - if err == nil && len(names) > 0 { - rec[fieldName] = []interface{}{fkID, names[fkID]} - } else { - rec[fieldName] = []interface{}{fkID, fmt.Sprintf("%s,%d", f.Comodel, fkID)} - } + key := m2oKey{fieldName, f.Comodel} + if names, ok := nameCache[key]; ok { + rec[fieldName] = []interface{}{fkID, names[fkID]} + } else { + rec[fieldName] = []interface{}{fkID, fmt.Sprintf("%s,%d", f.Comodel, fkID)} } } } } -// normalizeNullFields converts SQL NULL (Go nil) values to Odoo-expected defaults. -// In Python Odoo, empty fields return False (JSON false), not null. -// Without this, the webclient may render "null" as literal text. -// Mirrors: odoo/orm/fields.py convert_to_read() behaviour +// normalizeNullFields converts SQL NULL (Go nil) to Odoo's false convention. +// Without this, the webclient renders "null" as literal text. func normalizeNullFields(model string, records []orm.Values) { - m := orm.Registry.Get(model) - if m == nil { - return - } for _, rec := range records { for fieldName, val := range rec { - if val != nil { - continue - } - f := m.GetField(fieldName) - if f == nil { - continue - } - switch f.Type { - case orm.TypeChar, orm.TypeText, orm.TypeHTML, orm.TypeSelection: - rec[fieldName] = false - case orm.TypeMany2one: - rec[fieldName] = false - case orm.TypeInteger, orm.TypeFloat, orm.TypeMonetary: - rec[fieldName] = false - case orm.TypeBoolean: - rec[fieldName] = false - case orm.TypeDate, orm.TypeDatetime: - rec[fieldName] = false - default: + if val == nil { rec[fieldName] = false } }