package server import ( "fmt" "time" "odoo-go/pkg/orm" ) const ( defaultWebSearchLimit = 80 defaultO2MFetchLimit = 200 defaultNameSearchLimit = 8 ) // handleWebSearchRead implements the web_search_read method. // Mirrors: odoo/addons/web/models/models.py web_search_read() // Returns {length: N, records: [...]} instead of just records. func handleWebSearchRead(env *orm.Environment, model string, params CallKWParams) (interface{}, *RPCError) { rs := env.Model(model) // Parse domain from first arg (regular search_read) or kwargs (web_search_read) domain := parseDomain(params.Args) if domain == nil { if domainRaw, ok := params.KW["domain"].([]interface{}); ok && len(domainRaw) > 0 { domain = parseDomain([]interface{}{domainRaw}) } } // Parse specification from kwargs spec, _ := params.KW["specification"].(map[string]interface{}) fields := specToFields(spec) // Always include id hasID := false for _, f := range fields { if f == "id" { hasID = true break } } if !hasID { fields = append([]string{"id"}, fields...) } // Parse offset, limit, order offset := 0 limit := defaultWebSearchLimit order := "" if v, ok := params.KW["offset"].(float64); ok { offset = int(v) } if v, ok := params.KW["limit"].(float64); ok { limit = int(v) } if v, ok := params.KW["order"].(string); ok { order = v } // Get total count, respecting count_limit for optimization. // Mirrors: odoo/addons/web/models/models.py web_search_read() count_limit parameter countLimit := int64(0) if v, ok := params.KW["count_limit"].(float64); ok { countLimit = int64(v) } count, err := rs.SearchCount(domain) if err != nil { return nil, &RPCError{Code: -32000, Message: err.Error()} } if countLimit > 0 && count > countLimit { count = countLimit } // Search with offset/limit found, err := rs.Search(domain, orm.SearchOpts{ Offset: offset, Limit: limit, Order: order, }) if err != nil { return nil, &RPCError{Code: -32000, Message: err.Error()} } var records []orm.Values if !found.IsEmpty() { records, err = found.Read(fields) if err != nil { return nil, &RPCError{Code: -32000, Message: err.Error()} } } formatRecordsForWeb(env, model, records, spec) if records == nil { records = []orm.Values{} } return map[string]interface{}{ "length": count, "records": records, }, nil } // handleWebRead implements the web_read method. // Mirrors: odoo/addons/web/models/models.py web_read() func handleWebRead(env *orm.Environment, model string, params CallKWParams) (interface{}, *RPCError) { ids := parseIDs(params.Args) if len(ids) == 0 { return []orm.Values{}, nil } spec, _ := params.KW["specification"].(map[string]interface{}) fields := specToFields(spec) // Always include id hasID := false for _, f := range fields { if f == "id" { hasID = true break } } if !hasID { fields = append([]string{"id"}, fields...) } rs := env.Model(model) records, err := rs.Browse(ids...).Read(fields) if err != nil { return nil, &RPCError{Code: -32000, Message: err.Error()} } formatRecordsForWeb(env, model, records, spec) if records == nil { records = []orm.Values{} } 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{}) { m := orm.Registry.Get(modelName) if m == nil || spec == nil { return } for fieldName, fieldSpec := range spec { f := m.GetField(fieldName) if f == nil || f.Type != orm.TypeOne2many { continue } // Check if spec requests sub-fields specMap, ok := fieldSpec.(map[string]interface{}) if !ok { continue } subFieldsSpec, _ := specMap["fields"].(map[string]interface{}) // Determine which fields to read from child model var childFields []string if len(subFieldsSpec) > 0 { childFields = []string{"id"} for name := range subFieldsSpec { if name != "id" { childFields = append(childFields, name) } } } comodel := f.Comodel inverseField := f.InverseField if comodel == "" || inverseField == "" { continue } // Collect all parent IDs from records var parentIDs []int64 for _, rec := range records { parentID, ok := rec["id"].(int64) if !ok { if pid, ok := rec["id"].(int32); ok { parentID = int64(pid) } } if parentID > 0 { parentIDs = append(parentIDs, parentID) } } // Initialize all records with empty slices for _, rec := range records { rec[fieldName] = []interface{}{} } if len(parentIDs) == 0 { continue } // Single batched search: WHERE inverse_field IN (all parent IDs) childRS := env.Model(comodel) domain := orm.And(orm.Leaf(inverseField, "in", parentIDs)) found, err := childRS.Search(domain, orm.SearchOpts{Limit: defaultO2MFetchLimit}) if err != nil || found.IsEmpty() { continue } // Single batched read childRecords, err := found.Read(childFields) if err != nil { continue } // Format child records (M2O fields, dates, nulls) formatM2OFields(env, comodel, childRecords, subFieldsSpec) formatDateFields(comodel, childRecords) normalizeNullFields(comodel, childRecords) // Group child records by their inverse field (parent ID) grouped := make(map[int64][]interface{}) for _, cr := range childRecords { if pid, ok := orm.ToRecordID(cr[inverseField]); ok && pid > 0 { grouped[pid] = append(grouped[pid], cr) } } // Assign grouped children to each parent record for _, rec := range records { parentID, ok := rec["id"].(int64) if !ok { if pid, ok := rec["id"].(int32); ok { parentID = int64(pid) } } if children, ok := grouped[parentID]; ok { rec[fieldName] = children } } } } // specToFields extracts field names from a specification dict. // {"name": {}, "partner_id": {"fields": {"display_name": {}}}} → ["name", "partner_id"] func specToFields(spec map[string]interface{}) []string { if len(spec) == 0 { return nil } fields := make([]string, 0, len(spec)) for name := range spec { fields = append(fields, name) } return fields } // formatM2OFields converts Many2one field values from raw int to {id, display_name}. func formatM2OFields(env *orm.Environment, modelName string, records []orm.Values, spec map[string]interface{}) { m := orm.Registry.Get(modelName) if m == nil || spec == nil { 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 || f.Comodel == "" { continue } if _, ok := fieldSpec.(map[string]interface{}); !ok { continue } fkID, ok := orm.ToRecordID(rec[fieldName]) if !ok || fkID == 0 { rec[fieldName] = false continue } 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) to Odoo's false convention. // Without this, the webclient renders "null" as literal text. func normalizeNullFields(model string, records []orm.Values) { for _, rec := range records { for fieldName, val := range rec { if val == nil { rec[fieldName] = false } } } } // handleReadGroup dispatches web_read_group and read_group RPC calls. // Mirrors: odoo/addons/web/models/models.py web_read_group() + formatted_read_group() func (s *Server) handleReadGroup(rs *orm.Recordset, params CallKWParams) (interface{}, *RPCError) { // Parse domain domain := parseDomain(params.Args) if domain == nil { if domainRaw, ok := params.KW["domain"].([]interface{}); ok && len(domainRaw) > 0 { domain = parseDomain([]interface{}{domainRaw}) } } // Parse groupby var groupby []string if gb, ok := params.KW["groupby"].([]interface{}); ok { for _, g := range gb { if s, ok := g.(string); ok { groupby = append(groupby, s) } } } // Parse aggregates (web client sends "fields" or "aggregates") var aggregates []string if aggs, ok := params.KW["aggregates"].([]interface{}); ok { for _, a := range aggs { if s, ok := a.(string); ok { aggregates = append(aggregates, s) } } } // Always include __count hasCount := false for _, a := range aggregates { if a == "__count" { hasCount = true break } } if !hasCount { aggregates = append(aggregates, "__count") } // Parse opts opts := orm.ReadGroupOpts{} if v, ok := params.KW["limit"].(float64); ok { opts.Limit = int(v) } if v, ok := params.KW["offset"].(float64); ok { opts.Offset = int(v) } if v, ok := params.KW["order"].(string); ok { opts.Order = v } if len(groupby) == 0 { // No groupby: return total count only (like Python Odoo) count, _ := rs.SearchCount(domain) group := map[string]interface{}{ "__count": count, } for _, agg := range aggregates { if agg != "__count" { group[agg] = 0 } } if params.Method == "web_read_group" { return map[string]interface{}{ "groups": []interface{}{group}, "length": 1, }, nil } return []interface{}{group}, nil } // Execute ReadGroup results, err := rs.ReadGroup(domain, groupby, aggregates, opts) if err != nil { return nil, &RPCError{Code: -32000, Message: err.Error()} } // Format results for the web client // Mirrors: odoo/addons/web/models/models.py _web_read_group_format() groups := make([]interface{}, 0, len(results)) for _, r := range results { group := map[string]interface{}{ "__extra_domain": r.Domain, } // Groupby values for spec, val := range r.GroupValues { group[spec] = val } // Aggregate values for spec, val := range r.AggValues { group[spec] = val } // Ensure __count if _, ok := group["__count"]; !ok { group["__count"] = r.Count } groups = append(groups, group) } if groups == nil { groups = []interface{}{} } if params.Method == "web_read_group" { // web_read_group: also get total group count (without limit/offset) totalLen := len(results) if opts.Limit > 0 || opts.Offset > 0 { allResults, err := rs.ReadGroup(domain, groupby, []string{"__count"}) if err == nil { totalLen = len(allResults) } } return map[string]interface{}{ "groups": groups, "length": totalLen, }, nil } // Legacy read_group format return groups, nil } // formatDateFields converts date/datetime values to Odoo's expected string format. func formatDateFields(model string, records []orm.Values) { m := orm.Registry.Get(model) if m == nil { return } for _, rec := range records { for fieldName, val := range rec { f := m.GetField(fieldName) if f == nil { continue } if f.Type == orm.TypeDate || f.Type == orm.TypeDatetime { switch v := val.(type) { case time.Time: if f.Type == orm.TypeDate { rec[fieldName] = v.Format("2006-01-02") } else { rec[fieldName] = v.Format("2006-01-02 15:04:05") } case string: // Already a string, might need reformatting if t, err := time.Parse(time.RFC3339, v); err == nil { if f.Type == orm.TypeDate { rec[fieldName] = t.Format("2006-01-02") } else { rec[fieldName] = t.Format("2006-01-02 15:04:05") } } } } // Also convert boolean fields: Go nil → Odoo false if f.Type == orm.TypeBoolean && val == nil { rec[fieldName] = false } } } }