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>
This commit is contained in:
Marc
2026-04-02 19:26:08 +02:00
parent 06e49c878a
commit b57176de2f
29 changed files with 3243 additions and 111 deletions

View File

@@ -105,6 +105,7 @@ func Not(node DomainNode) Domain {
// Mirrors: odoo/orm/domains.py Domain._to_sql()
type DomainCompiler struct {
model *Model
env *Environment // For operators that need DB access (child_of, parent_of, any, not any)
params []interface{}
joins []joinClause
aliasCounter int
@@ -193,11 +194,35 @@ func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
case Condition:
return dc.compileCondition(n)
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))
if err != nil {
return "", err
}
_ = subParams // params already appended inside compileDomainGroup
return subSQL, nil
}
return "", fmt.Errorf("unexpected domain node at position %d: %v", pos, node)
}
// compileDomainGroup compiles a sub-domain that was wrapped via domainGroup.
// It reuses the same DomainCompiler (sharing params and joins) so parameter
// indices stay consistent with the outer query.
func (dc *DomainCompiler) compileDomainGroup(sub Domain) (string, []interface{}, error) {
if len(sub) == 0 {
return "TRUE", nil, nil
}
sql, err := dc.compileNodes(sub, 0)
if err != nil {
return "", nil, err
}
return sql, nil, nil
}
func (dc *DomainCompiler) compileCondition(c Condition) (string, error) {
if !validOperators[c.Operator] {
return "", fmt.Errorf("invalid operator: %q", c.Operator)
@@ -285,6 +310,18 @@ func (dc *DomainCompiler) compileSimpleCondition(column, operator string, value
dc.params = append(dc.params, value)
return fmt.Sprintf("%q ILIKE $%d", column, paramIdx), nil
case "child_of":
return dc.compileHierarchyOp(column, value, true)
case "parent_of":
return dc.compileHierarchyOp(column, value, false)
case "any":
return dc.compileAnyOp(column, value, false)
case "not any":
return dc.compileAnyOp(column, value, true)
default:
return "", fmt.Errorf("unhandled operator: %q", operator)
}
@@ -396,6 +433,272 @@ func (dc *DomainCompiler) compileQualifiedCondition(qualifiedColumn, operator st
}
}
// compileHierarchyOp implements child_of / parent_of by querying the DB for hierarchy IDs.
// Mirrors: odoo/orm/domains.py _expression._get_hierarchy_ids
//
// - child_of: finds all descendants via parent_id traversal, then "id" IN (...)
// - parent_of: finds all ancestors via parent_id traversal, then "id" IN (...)
//
// Requires dc.env to be set for DB access.
func (dc *DomainCompiler) compileHierarchyOp(column string, value Value, isChildOf bool) (string, error) {
if dc.env == nil {
return "", fmt.Errorf("child_of/parent_of requires Environment on DomainCompiler")
}
// Normalize the root ID(s)
rootIDs := toInt64Slice(value)
if len(rootIDs) == 0 {
return "FALSE", nil
}
table := dc.model.Table()
var allIDs map[int64]bool
if isChildOf {
// child_of: find all descendants (including roots) via parent_id
allIDs = make(map[int64]bool)
queue := make([]int64, len(rootIDs))
copy(queue, rootIDs)
for _, id := range rootIDs {
allIDs[id] = true
}
for len(queue) > 0 {
// Build placeholders for current batch
placeholders := make([]string, len(queue))
args := make([]interface{}, len(queue))
for i, id := range queue {
args[i] = id
placeholders[i] = fmt.Sprintf("$%d", i+1)
}
query := fmt.Sprintf(
`SELECT "id" FROM %q WHERE "parent_id" IN (%s)`,
table, strings.Join(placeholders, ", "),
)
rows, err := dc.env.tx.Query(dc.env.ctx, query, args...)
if err != nil {
return "", fmt.Errorf("child_of query: %w", err)
}
var nextQueue []int64
for rows.Next() {
var childID int64
if err := rows.Scan(&childID); err != nil {
rows.Close()
return "", err
}
if !allIDs[childID] {
allIDs[childID] = true
nextQueue = append(nextQueue, childID)
}
}
rows.Close()
if err := rows.Err(); err != nil {
return "", err
}
queue = nextQueue
}
} else {
// parent_of: find all ancestors (including roots) via parent_id
allIDs = make(map[int64]bool)
queue := make([]int64, len(rootIDs))
copy(queue, rootIDs)
for _, id := range rootIDs {
allIDs[id] = true
}
for len(queue) > 0 {
placeholders := make([]string, len(queue))
args := make([]interface{}, len(queue))
for i, id := range queue {
args[i] = id
placeholders[i] = fmt.Sprintf("$%d", i+1)
}
query := fmt.Sprintf(
`SELECT "parent_id" FROM %q WHERE "id" IN (%s) AND "parent_id" IS NOT NULL`,
table, strings.Join(placeholders, ", "),
)
rows, err := dc.env.tx.Query(dc.env.ctx, query, args...)
if err != nil {
return "", fmt.Errorf("parent_of query: %w", err)
}
var nextQueue []int64
for rows.Next() {
var parentID int64
if err := rows.Scan(&parentID); err != nil {
rows.Close()
return "", err
}
if !allIDs[parentID] {
allIDs[parentID] = true
nextQueue = append(nextQueue, parentID)
}
}
rows.Close()
if err := rows.Err(); err != nil {
return "", err
}
queue = nextQueue
}
}
if len(allIDs) == 0 {
return "FALSE", nil
}
// Build "id" IN (1, 2, 3, ...) with parameters
paramIdx := len(dc.params) + 1
placeholders := make([]string, 0, len(allIDs))
for id := range allIDs {
dc.params = append(dc.params, id)
placeholders = append(placeholders, fmt.Sprintf("$%d", paramIdx))
paramIdx++
}
return fmt.Sprintf("%q IN (%s)", column, strings.Join(placeholders, ", ")), nil
}
// compileAnyOp implements 'any' and 'not any' operators.
// Mirrors: odoo/orm/domains.py for 'any' / 'not any' operators
//
// - any: EXISTS (SELECT 1 FROM comodel WHERE comodel.fk = model.id AND <subdomain>)
// - not any: NOT EXISTS (...)
//
// The value must be a Domain (sub-domain) to apply on the comodel.
func (dc *DomainCompiler) compileAnyOp(column string, value Value, negate bool) (string, error) {
// Resolve the field to find the comodel
f := dc.model.GetField(column)
if f == nil {
return "", fmt.Errorf("any/not any: field %q not found on %s", column, dc.model.Name())
}
comodel := Registry.Get(f.Comodel)
if comodel == nil {
return "", fmt.Errorf("any/not any: comodel %q not found for field %q", f.Comodel, column)
}
// The value should be a Domain (sub-domain for the comodel)
subDomain, ok := value.(Domain)
if !ok {
return "", fmt.Errorf("any/not any: value must be a Domain, got %T", value)
}
// Compile the sub-domain against the comodel
subCompiler := &DomainCompiler{model: comodel, env: dc.env}
subWhere, subParams, err := subCompiler.Compile(subDomain)
if err != nil {
return "", fmt.Errorf("any/not any: compile subdomain: %w", err)
}
// Rebase parameter indices: shift them by the current param count
baseIdx := len(dc.params)
dc.params = append(dc.params, subParams...)
rebased := subWhere
// Replace $N with $(N+baseIdx) in the sub-where clause
for i := len(subParams); i >= 1; i-- {
old := fmt.Sprintf("$%d", i)
new := fmt.Sprintf("$%d", i+baseIdx)
rebased = strings.ReplaceAll(rebased, old, new)
}
// Determine the join condition based on field type
var joinCond string
switch f.Type {
case TypeOne2many:
// One2many: comodel has a FK pointing back to this model
inverseField := f.InverseField
if inverseField == "" {
return "", fmt.Errorf("any/not any: One2many field %q has no InverseField", column)
}
inverseF := comodel.GetField(inverseField)
if inverseF == nil {
return "", fmt.Errorf("any/not any: inverse field %q not found on %s", inverseField, comodel.Name())
}
joinCond = fmt.Sprintf("%q.%q = %q.\"id\"", comodel.Table(), inverseF.Column(), dc.model.Table())
case TypeMany2many:
// Many2many: use junction table
relation := f.Relation
if relation == "" {
t1, t2 := dc.model.Table(), comodel.Table()
if t1 > t2 {
t1, t2 = t2, t1
}
relation = fmt.Sprintf("%s_%s_rel", t1, t2)
}
col1 := f.Column1
if col1 == "" {
col1 = dc.model.Table() + "_id"
}
col2 := f.Column2
if col2 == "" {
col2 = comodel.Table() + "_id"
}
joinCond = fmt.Sprintf(
"%q.\"id\" IN (SELECT %q FROM %q WHERE %q = %q.\"id\")",
comodel.Table(), col2, relation, col1, dc.model.Table(),
)
case TypeMany2one:
// Many2one: this model has the FK
joinCond = fmt.Sprintf("%q.\"id\" = %q.%q", comodel.Table(), dc.model.Table(), f.Column())
default:
return "", fmt.Errorf("any/not any: field %q is type %s, expected relational", column, f.Type)
}
subJoins := subCompiler.JoinSQL()
prefix := "EXISTS"
if negate {
prefix = "NOT EXISTS"
}
return fmt.Sprintf("%s (SELECT 1 FROM %q%s WHERE %s AND %s)",
prefix, comodel.Table(), subJoins, joinCond, rebased,
), nil
}
// toInt64Slice normalizes a value to []int64 for hierarchy operators.
func toInt64Slice(value Value) []int64 {
switch v := value.(type) {
case int64:
return []int64{v}
case int:
return []int64{int64(v)}
case int32:
return []int64{int64(v)}
case float64:
return []int64{int64(v)}
case []int64:
return v
case []int:
out := make([]int64, len(v))
for i, x := range v {
out[i] = int64(x)
}
return out
case []interface{}:
out := make([]int64, 0, len(v))
for _, x := range v {
switch n := x.(type) {
case int64:
out = append(out, n)
case int:
out = append(out, int64(n))
case float64:
out = append(out, int64(n))
}
}
return out
}
return nil
}
// normalizeSlice converts typed slices to []interface{} for IN/NOT IN operators.
func normalizeSlice(value Value) []interface{} {
switch v := value.(type) {