Simplify: batch M2O, deduplicate formatting, clean dead code
Code reuse: - formatRecordsForWeb() consolidates 4-call formatting sequence (was duplicated in handleWebSearchRead + handleWebRead) - ToRecordID() public alias for cross-package ID extraction Performance: - M2O NameGet now batched: collect all FK IDs per field, single NameGet per comodel instead of per-record (N+1 → 1) Quality: - normalizeNullFields: 30-line switch (all returning false) → 5 lines - domain.go: remove unused _ = subParams assignment - Net -14 lines Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -198,11 +198,10 @@ func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
|
|||||||
case domainGroup:
|
case domainGroup:
|
||||||
// domainGroup wraps a sub-domain as a single node.
|
// domainGroup wraps a sub-domain as a single node.
|
||||||
// Compile it recursively as a full domain.
|
// Compile it recursively as a full domain.
|
||||||
subSQL, subParams, err := dc.compileDomainGroup(Domain(n))
|
subSQL, _, err := dc.compileDomainGroup(Domain(n))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
_ = subParams // params already appended inside compileDomainGroup
|
|
||||||
return subSQL, nil
|
return subSQL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -269,6 +269,9 @@ func ParseCommands(raw interface{}) ([]Command, bool) {
|
|||||||
return cmds, true
|
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) {
|
func toInt64(v interface{}) (int64, bool) {
|
||||||
switch n := v.(type) {
|
switch n := v.(type) {
|
||||||
case float64:
|
case float64:
|
||||||
|
|||||||
@@ -83,17 +83,7 @@ func handleWebSearchRead(env *orm.Environment, model string, params CallKWParams
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format M2O fields as {id, display_name} when spec requests it
|
formatRecordsForWeb(env, model, records, spec)
|
||||||
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)
|
|
||||||
|
|
||||||
if records == nil {
|
if records == nil {
|
||||||
records = []orm.Values{}
|
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()}
|
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
formatM2OFields(env, model, records, spec)
|
formatRecordsForWeb(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)
|
|
||||||
|
|
||||||
if records == nil {
|
if records == nil {
|
||||||
records = []orm.Values{}
|
records = []orm.Values{}
|
||||||
@@ -151,6 +132,14 @@ func handleWebRead(env *orm.Environment, model string, params CallKWParams) (int
|
|||||||
return records, nil
|
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.
|
// formatO2MFields fetches One2many child records and adds them to the response.
|
||||||
// Mirrors: odoo/addons/web/models/models.py web_read() O2M handling
|
// 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{}) {
|
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
|
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 _, rec := range records {
|
||||||
for fieldName, fieldSpec := range spec {
|
for fieldName, fieldSpec := range spec {
|
||||||
f := m.GetField(fieldName)
|
f := m.GetField(fieldName)
|
||||||
if f == nil || f.Type != orm.TypeMany2one {
|
if f == nil || f.Type != orm.TypeMany2one || f.Comodel == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if _, ok := fieldSpec.(map[string]interface{}); !ok {
|
||||||
// Accept any spec entry for M2O fields (even empty {} means include it)
|
|
||||||
_, ok := fieldSpec.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
fkID, ok := orm.ToRecordID(rec[fieldName])
|
||||||
// Get the raw FK ID
|
if !ok || fkID == 0 {
|
||||||
rawID := rec[fieldName]
|
rec[fieldName] = false
|
||||||
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
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
key := m2oKey{fieldName, f.Comodel}
|
||||||
// Fetch display_name from comodel — return as [id, "name"] array
|
if names, ok := nameCache[key]; ok {
|
||||||
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]}
|
rec[fieldName] = []interface{}{fkID, names[fkID]}
|
||||||
} else {
|
} else {
|
||||||
rec[fieldName] = []interface{}{fkID, fmt.Sprintf("%s,%d", f.Comodel, fkID)}
|
rec[fieldName] = []interface{}{fkID, fmt.Sprintf("%s,%d", f.Comodel, fkID)}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeNullFields converts SQL NULL (Go nil) values to Odoo-expected defaults.
|
// normalizeNullFields converts SQL NULL (Go nil) to Odoo's false convention.
|
||||||
// In Python Odoo, empty fields return False (JSON false), not null.
|
// Without this, the webclient renders "null" as literal text.
|
||||||
// Without this, the webclient may render "null" as literal text.
|
|
||||||
// Mirrors: odoo/orm/fields.py convert_to_read() behaviour
|
|
||||||
func normalizeNullFields(model string, records []orm.Values) {
|
func normalizeNullFields(model string, records []orm.Values) {
|
||||||
m := orm.Registry.Get(model)
|
|
||||||
if m == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, rec := range records {
|
for _, rec := range records {
|
||||||
for fieldName, val := range rec {
|
for fieldName, val := range rec {
|
||||||
if val != nil {
|
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:
|
|
||||||
rec[fieldName] = false
|
rec[fieldName] = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user