package orm import ( "fmt" "strings" ) // Domain represents a search filter expression. // Mirrors: odoo/orm/domains.py Domain class // // Odoo uses prefix (Polish) notation: // // ['&', ('name', 'ilike', 'test'), ('active', '=', True)] // // Go equivalent: // // And(Leaf("name", "ilike", "test"), Leaf("active", "=", true)) type Domain []DomainNode // DomainNode is either an Operator or a Condition (leaf). type DomainNode interface { isDomainNode() } // Operator is a logical operator in a domain expression. // Mirrors: odoo/orm/domains.py DOMAIN_OPERATORS type Operator string const ( OpAnd Operator = "&" OpOr Operator = "|" OpNot Operator = "!" ) func (o Operator) isDomainNode() {} // Condition is a leaf node in a domain expression. // Mirrors: odoo/orm/domains.py DomainLeaf // // Odoo: ('field_name', 'operator', value) type Condition struct { Field string // Field name (supports dot notation: "partner_id.name") Operator string // Comparison operator Value Value // Comparison value } func (c Condition) isDomainNode() {} // Valid comparison operators. // Mirrors: odoo/orm/domains.py COMPARISON_OPERATORS var validOperators = map[string]bool{ "=": true, "!=": true, "<": true, ">": true, "<=": true, ">=": true, "in": true, "not in": true, "like": true, "not like": true, "ilike": true, "not ilike": true, "=like": true, "=ilike": true, "any": true, "not any": true, "child_of": true, "parent_of": true, } // Leaf creates a domain condition (leaf node). func Leaf(field, operator string, value Value) Condition { return Condition{Field: field, Operator: operator, Value: value} } // And combines conditions with AND (default in Odoo). func And(nodes ...DomainNode) Domain { if len(nodes) == 0 { return nil } if len(nodes) == 1 { return Domain{nodes[0]} } result := Domain{} for i := 0; i < len(nodes)-1; i++ { result = append(result, OpAnd) } result = append(result, nodes...) return result } // Or combines conditions with OR. func Or(nodes ...DomainNode) Domain { if len(nodes) == 0 { return nil } if len(nodes) == 1 { return Domain{nodes[0]} } result := Domain{} for i := 0; i < len(nodes)-1; i++ { result = append(result, OpOr) } result = append(result, nodes...) return result } // Not negates a condition. func Not(node DomainNode) Domain { return Domain{OpNot, node} } // DomainCompiler compiles a Domain to SQL WHERE clause. // 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 } type joinClause struct { table string alias string on string } // CompileResult holds the compiled SQL WHERE clause, JOINs, and parameters. type CompileResult struct { Where string Joins string Params []interface{} } // Compile converts a domain to a SQL WHERE clause with parameters and JOINs. func (dc *DomainCompiler) Compile(domain Domain) (string, []interface{}, error) { if len(domain) == 0 { return "TRUE", nil, nil } dc.params = nil dc.joins = nil dc.aliasCounter = 0 sql, err := dc.compileNodes(domain, 0) if err != nil { return "", nil, err } return sql, dc.params, nil } // JoinSQL returns the SQL JOIN clauses generated during compilation. func (dc *DomainCompiler) JoinSQL() string { if len(dc.joins) == 0 { return "" } var parts []string for _, j := range dc.joins { parts = append(parts, fmt.Sprintf("LEFT JOIN %q AS %q ON %s", j.table, j.alias, j.on)) } return " " + strings.Join(parts, " ") } func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) { if pos >= len(domain) { return "TRUE", nil } node := domain[pos] switch n := node.(type) { case Operator: switch n { case OpAnd: left, err := dc.compileNodes(domain, pos+1) if err != nil { return "", err } right, err := dc.compileNodes(domain, pos+2) if err != nil { return "", err } return fmt.Sprintf("(%s AND %s)", left, right), nil case OpOr: left, err := dc.compileNodes(domain, pos+1) if err != nil { return "", err } right, err := dc.compileNodes(domain, pos+2) if err != nil { return "", err } return fmt.Sprintf("(%s OR %s)", left, right), nil case OpNot: inner, err := dc.compileNodes(domain, pos+1) if err != nil { return "", err } return fmt.Sprintf("(NOT %s)", inner), nil } 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, _, err := dc.compileDomainGroup(Domain(n)) if err != nil { return "", err } 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) } // Handle dot notation (e.g., "partner_id.name") parts := strings.Split(c.Field, ".") column := parts[0] // TODO: Handle JOINs for dot notation paths // For now, only support direct fields if len(parts) > 1 { // Placeholder for JOIN resolution return dc.compileJoinedCondition(parts, c.Operator, c.Value) } return dc.compileSimpleCondition(column, c.Operator, c.Value) } func (dc *DomainCompiler) compileSimpleCondition(column, operator string, value Value) (string, error) { paramIdx := len(dc.params) + 1 switch operator { case "=", "!=", "<", ">", "<=", ">=": if value == nil || value == false { if operator == "=" { return fmt.Sprintf("%q IS NULL", column), nil } return fmt.Sprintf("%q IS NOT NULL", column), nil } dc.params = append(dc.params, value) return fmt.Sprintf("%q %s $%d", column, operator, paramIdx), nil case "in": vals := normalizeSlice(value) if vals == nil { return "", fmt.Errorf("'in' operator requires a slice value") } if len(vals) == 0 { return "FALSE", nil } placeholders := make([]string, len(vals)) for i, v := range vals { dc.params = append(dc.params, v) placeholders[i] = fmt.Sprintf("$%d", paramIdx+i) } return fmt.Sprintf("%q IN (%s)", column, strings.Join(placeholders, ", ")), nil case "not in": vals := normalizeSlice(value) if vals == nil { return "", fmt.Errorf("'not in' operator requires a slice value") } if len(vals) == 0 { return "TRUE", nil } placeholders := make([]string, len(vals)) for i, v := range vals { dc.params = append(dc.params, v) placeholders[i] = fmt.Sprintf("$%d", paramIdx+i) } return fmt.Sprintf("%q NOT IN (%s)", column, strings.Join(placeholders, ", ")), nil case "like": dc.params = append(dc.params, wrapLikeValue(value)) return fmt.Sprintf("%q LIKE $%d", column, paramIdx), nil case "not like": dc.params = append(dc.params, wrapLikeValue(value)) return fmt.Sprintf("%q NOT LIKE $%d", column, paramIdx), nil case "ilike": dc.params = append(dc.params, wrapLikeValue(value)) return fmt.Sprintf("%q ILIKE $%d", column, paramIdx), nil case "not ilike": dc.params = append(dc.params, wrapLikeValue(value)) return fmt.Sprintf("%q NOT ILIKE $%d", column, paramIdx), nil case "=like": dc.params = append(dc.params, value) return fmt.Sprintf("%q LIKE $%d", column, paramIdx), nil case "=ilike": 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) } } // compileJoinedCondition resolves dot-notation paths (e.g., "partner_id.country_id.code") // by generating LEFT JOINs through the relational chain. func (dc *DomainCompiler) compileJoinedCondition(fieldPath []string, operator string, value Value) (string, error) { currentModel := dc.model currentAlias := dc.model.Table() // Walk the path: each segment except the last is a Many2one FK to JOIN through for i := 0; i < len(fieldPath)-1; i++ { fieldName := fieldPath[i] f := currentModel.GetField(fieldName) if f == nil { return "", fmt.Errorf("field %q not found on %s", fieldName, currentModel.Name()) } if f.Type != TypeMany2one { return "", fmt.Errorf("field %q on %s is not Many2one, cannot traverse", fieldName, currentModel.Name()) } comodel := Registry.Get(f.Comodel) if comodel == nil { return "", fmt.Errorf("comodel %q not found for field %q", f.Comodel, fieldName) } // Generate alias and JOIN dc.aliasCounter++ alias := fmt.Sprintf("_j%d", dc.aliasCounter) dc.joins = append(dc.joins, joinClause{ table: comodel.Table(), alias: alias, on: fmt.Sprintf("%s.%q = %q.\"id\"", currentAlias, f.Column(), alias), }) currentModel = comodel currentAlias = alias } // The last segment is the actual field to filter on leafField := fieldPath[len(fieldPath)-1] qualifiedColumn := fmt.Sprintf("%s.%q", currentAlias, leafField) return dc.compileQualifiedCondition(qualifiedColumn, operator, value) } // compileQualifiedCondition compiles a condition with a fully qualified column (alias.column). func (dc *DomainCompiler) compileQualifiedCondition(qualifiedColumn, operator string, value Value) (string, error) { paramIdx := len(dc.params) + 1 switch operator { case "=", "!=", "<", ">", "<=", ">=": if value == nil || value == false { if operator == "=" { return fmt.Sprintf("%s IS NULL", qualifiedColumn), nil } return fmt.Sprintf("%s IS NOT NULL", qualifiedColumn), nil } dc.params = append(dc.params, value) return fmt.Sprintf("%s %s $%d", qualifiedColumn, operator, paramIdx), nil case "in", "not in": vals := normalizeSlice(value) if vals == nil { return "FALSE", nil } if len(vals) == 0 { if operator == "in" { return "FALSE", nil } return "TRUE", nil } placeholders := make([]string, len(vals)) for i, v := range vals { dc.params = append(dc.params, v) placeholders[i] = fmt.Sprintf("$%d", paramIdx+i) } op := "IN" if operator == "not in" { op = "NOT IN" } return fmt.Sprintf("%s %s (%s)", qualifiedColumn, op, strings.Join(placeholders, ", ")), nil case "like", "not like", "ilike", "not ilike": dc.params = append(dc.params, wrapLikeValue(value)) sqlOp := "LIKE" switch operator { case "not like": sqlOp = "NOT LIKE" case "ilike": sqlOp = "ILIKE" case "not ilike": sqlOp = "NOT ILIKE" } return fmt.Sprintf("%s %s $%d", qualifiedColumn, sqlOp, paramIdx), nil case "=like": dc.params = append(dc.params, value) return fmt.Sprintf("%s LIKE $%d", qualifiedColumn, paramIdx), nil case "=ilike": dc.params = append(dc.params, value) return fmt.Sprintf("%s ILIKE $%d", qualifiedColumn, paramIdx), nil default: dc.params = append(dc.params, value) return fmt.Sprintf("%s %s $%d", qualifiedColumn, operator, paramIdx), nil } } // 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 ) // - 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) { case []interface{}: return v case []int64: out := make([]interface{}, len(v)) for i, x := range v { out[i] = x } return out case []float64: out := make([]interface{}, len(v)) for i, x := range v { out[i] = x } return out case []string: out := make([]interface{}, len(v)) for i, x := range v { out[i] = x } return out case []int: out := make([]interface{}, len(v)) for i, x := range v { out[i] = x } return out } return nil } // wrapLikeValue wraps a string value with % wildcards for LIKE/ILIKE operators, // matching Odoo's behavior where ilike/like auto-wrap the search term. // If the value already contains %, it is left as-is. // Mirrors: odoo/orm/domains.py _expression._unaccent_wrap (value wrapping) func wrapLikeValue(value Value) Value { s, ok := value.(string) if !ok { return value } if strings.Contains(s, "%") || strings.Contains(s, "_") { return value // Already has wildcards, leave as-is } return "%" + s + "%" }