Files
goodie/pkg/orm/relational.go
Marc b57176de2f Bring odoo-go to ~70%: read_group, record rules, admin, sessions
Phase 1: read_group/web_read_group with SQL GROUP BY, aggregates
  (sum/avg/min/max/count/array_agg/sum_currency), date granularity,
  M2O groupby resolution to [id, display_name].

Phase 2: Record rules with domain_force parsing (Python literal parser),
  global AND + group OR merging. Domain operators: child_of, parent_of,
  any, not any compiled to SQL hierarchy/EXISTS queries.

Phase 3: Button dispatch via /web/dataset/call_button, method return
  values interpreted as actions. Payment register wizard
  (account.payment.register) for sale→invoice→pay flow.

Phase 4: ir.filters, ir.default, product fields expanded, SO line
  product_id onchange, ir_model+ir_model_fields DB seeding.

Phase 5: CSV export (/web/export/csv), attachment upload/download
  via ir.attachment, fields_get with aggregator hints.

Admin/System: Session persistence (PostgreSQL-backed), ir.config_parameter
  with get_param/set_param, ir.cron, ir.logging, res.lang, res.config.settings
  with company-related fields, Settings form view. Technical menu with
  Views/Actions/Parameters/Security/Logging sub-menus. User change_password,
  preferences. Password never exposed in UI/API.

Bugfixes: false→nil for varchar/int fields, int32 in toInt64, call_button
  route with trailing slash, create_invoices returns action, search view
  always included, get_formview_action, name_create, ir.http stub.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 19:26:08 +02:00

285 lines
6.8 KiB
Go

package orm
import (
"fmt"
"strings"
)
// ProcessO2MCommands processes One2many field commands after a Create/Write.
// Mirrors: odoo/orm/fields_relational.py One2many write logic
//
// Commands:
//
// CmdCreate(vals) → Create child record with inverse_field = parentID
// CmdUpdate(id, vals) → Update child record
// CmdDelete(id) → Delete child record
func ProcessO2MCommands(env *Environment, f *Field, parentID int64, cmds []Command) error {
if f.Comodel == "" || f.InverseField == "" {
return fmt.Errorf("orm: O2M field %q missing comodel or inverse_field", f.Name)
}
comodelRS := env.Model(f.Comodel)
for _, cmd := range cmds {
switch cmd.Operation {
case CommandCreate:
vals := cmd.Values
if vals == nil {
vals = make(Values)
}
vals[f.InverseField] = parentID
if _, err := comodelRS.Create(vals); err != nil {
return fmt.Errorf("orm: O2M create on %s: %w", f.Comodel, err)
}
case CommandUpdate:
if cmd.ID <= 0 {
continue
}
if err := comodelRS.Browse(cmd.ID).Write(cmd.Values); err != nil {
return fmt.Errorf("orm: O2M update %s(%d): %w", f.Comodel, cmd.ID, err)
}
case CommandDelete:
if cmd.ID <= 0 {
continue
}
if err := comodelRS.Browse(cmd.ID).Unlink(); err != nil {
return fmt.Errorf("orm: O2M delete %s(%d): %w", f.Comodel, cmd.ID, err)
}
case CommandClear:
// Delete all children linked to this parent
children, err := comodelRS.Search(And(Leaf(f.InverseField, "=", parentID)))
if err != nil {
return err
}
if err := children.Unlink(); err != nil {
return err
}
}
}
return nil
}
// ProcessM2MCommands processes Many2many field commands after a Create/Write.
// Mirrors: odoo/orm/fields_relational.py Many2many write logic
//
// Commands:
//
// CmdLink(id) → Add link in junction table
// CmdUnlink(id) → Remove link from junction table
// CmdClear() → Remove all links
// CmdSet(ids) → Replace all links
// CmdCreate(vals) → Create comodel record then link it
func ProcessM2MCommands(env *Environment, f *Field, parentID int64, cmds []Command) error {
jt := f.JunctionTable()
col1 := f.JunctionCol1()
col2 := f.JunctionCol2()
if jt == "" || col1 == "" || col2 == "" {
return fmt.Errorf("orm: M2M field %q: cannot determine junction table", f.Name)
}
for _, cmd := range cmds {
switch cmd.Operation {
case CommandLink:
_, err := env.tx.Exec(env.ctx, fmt.Sprintf(
`INSERT INTO %q (%q, %q) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
jt, col1, col2,
), parentID, cmd.ID)
if err != nil {
return fmt.Errorf("orm: M2M link %s: %w", f.Name, err)
}
case CommandUnlink:
_, err := env.tx.Exec(env.ctx, fmt.Sprintf(
`DELETE FROM %q WHERE %q = $1 AND %q = $2`,
jt, col1, col2,
), parentID, cmd.ID)
if err != nil {
return fmt.Errorf("orm: M2M unlink %s: %w", f.Name, err)
}
case CommandClear:
_, err := env.tx.Exec(env.ctx, fmt.Sprintf(
`DELETE FROM %q WHERE %q = $1`,
jt, col1,
), parentID)
if err != nil {
return fmt.Errorf("orm: M2M clear %s: %w", f.Name, err)
}
case CommandSet:
// Clear then link all
if _, err := env.tx.Exec(env.ctx, fmt.Sprintf(
`DELETE FROM %q WHERE %q = $1`,
jt, col1,
), parentID); err != nil {
return err
}
for _, targetID := range cmd.IDs {
if _, err := env.tx.Exec(env.ctx, fmt.Sprintf(
`INSERT INTO %q (%q, %q) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
jt, col1, col2,
), parentID, targetID); err != nil {
return err
}
}
case CommandCreate:
// Create comodel record then link
comodelRS := env.Model(f.Comodel)
created, err := comodelRS.Create(cmd.Values)
if err != nil {
return err
}
if _, err := env.tx.Exec(env.ctx, fmt.Sprintf(
`INSERT INTO %q (%q, %q) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
jt, col1, col2,
), parentID, created.ID()); err != nil {
return err
}
}
}
return nil
}
// ReadM2MField reads the linked IDs for a Many2many field.
func ReadM2MField(env *Environment, f *Field, parentIDs []int64) (map[int64][]int64, error) {
jt := f.JunctionTable()
col1 := f.JunctionCol1()
col2 := f.JunctionCol2()
if jt == "" {
return nil, nil
}
placeholders := make([]string, len(parentIDs))
args := make([]interface{}, len(parentIDs))
for i, id := range parentIDs {
args[i] = id
placeholders[i] = fmt.Sprintf("$%d", i+1)
}
query := fmt.Sprintf(
`SELECT %q, %q FROM %q WHERE %q IN (%s)`,
col1, col2, jt, col1, strings.Join(placeholders, ", "),
)
rows, err := env.tx.Query(env.ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[int64][]int64)
for rows.Next() {
var pid, tid int64
if err := rows.Scan(&pid, &tid); err != nil {
return nil, err
}
result[pid] = append(result[pid], tid)
}
return result, rows.Err()
}
// ParseCommands converts a JSON value to a slice of Commands.
// JSON format: [[0, 0, {vals}], [4, id, 0], [6, 0, [ids]], ...]
func ParseCommands(raw interface{}) ([]Command, bool) {
list, ok := raw.([]interface{})
if !ok || len(list) == 0 {
return nil, false
}
// Check if this looks like commands (list of lists)
first, ok := list[0].([]interface{})
if !ok {
return nil, false
}
if len(first) < 2 {
return nil, false
}
var cmds []Command
for _, item := range list {
tuple, ok := item.([]interface{})
if !ok || len(tuple) < 2 {
continue
}
op, ok := toInt64(tuple[0])
if !ok {
continue
}
switch CommandOp(op) {
case CommandCreate: // [0, _, {vals}]
vals := make(Values)
if len(tuple) > 2 {
if m, ok := tuple[2].(map[string]interface{}); ok {
vals = m
}
}
cmds = append(cmds, CmdCreate(vals))
case CommandUpdate: // [1, id, {vals}]
id, _ := toInt64(tuple[1])
vals := make(Values)
if len(tuple) > 2 {
if m, ok := tuple[2].(map[string]interface{}); ok {
vals = m
}
}
cmds = append(cmds, CmdUpdate(id, vals))
case CommandDelete: // [2, id, _]
id, _ := toInt64(tuple[1])
cmds = append(cmds, CmdDelete(id))
case CommandUnlink: // [3, id, _]
id, _ := toInt64(tuple[1])
cmds = append(cmds, CmdUnlink(id))
case CommandLink: // [4, id, _]
id, _ := toInt64(tuple[1])
cmds = append(cmds, CmdLink(id))
case CommandClear: // [5, _, _]
cmds = append(cmds, CmdClear())
case CommandSet: // [6, _, [ids]]
var ids []int64
if len(tuple) > 2 {
if arr, ok := tuple[2].([]interface{}); ok {
for _, v := range arr {
if id, ok := toInt64(v); ok {
ids = append(ids, id)
}
}
}
}
cmds = append(cmds, CmdSet(ids))
}
}
if len(cmds) == 0 {
return nil, false
}
return cmds, true
}
func toInt64(v interface{}) (int64, bool) {
switch n := v.(type) {
case float64:
return int64(n), true
case int64:
return n, true
case int32:
return int64(n), true
case int:
return int64(n), true
}
return 0, false
}