Files
goodie/pkg/server/web_methods.go
Marc 6fd9cdea1b Simplify: O2M batch, domain dedup, db.go split, constants
Performance:
- O2M formatO2MFields: N+1 → single batched query per O2M field
  (collect parent IDs, WHERE inverse IN (...), group by parent)

Code dedup:
- domain.go compileSimpleCondition: 80 lines → 12 lines by delegating
  to compileQualifiedCondition (eliminates duplicate operator handling)
- db.go SeedWithSetup: 170-line monolith → 5 focused sub-functions
  (seedCurrencyAndCountry, seedCompanyAndAdmin, seedJournalsAndSequences,
  seedChartOfAccounts, resetSequences)

Quality:
- Magic numbers (80, 200, 8) replaced with named constants
- Net -34 lines

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:11:37 +02:00

511 lines
13 KiB
Go

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
}
}
}
}