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 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) } return "", fmt.Errorf("unexpected domain node at position %d: %v", pos, node) } 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, value) return fmt.Sprintf("%q LIKE $%d", column, paramIdx), nil case "not like": dc.params = append(dc.params, value) return fmt.Sprintf("%q NOT LIKE $%d", column, paramIdx), nil case "ilike": dc.params = append(dc.params, value) return fmt.Sprintf("%q ILIKE $%d", column, paramIdx), nil case "not ilike": dc.params = append(dc.params, 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 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", "=like", "=ilike": dc.params = append(dc.params, value) sqlOp := strings.ToUpper(strings.TrimPrefix(operator, "=")) if strings.HasPrefix(operator, "=") { sqlOp = strings.ToUpper(operator[1:]) } switch operator { case "like": sqlOp = "LIKE" case "not like": sqlOp = "NOT LIKE" case "ilike", "=ilike": sqlOp = "ILIKE" case "not ilike": sqlOp = "NOT ILIKE" case "=like": sqlOp = "LIKE" } return fmt.Sprintf("%s %s $%d", qualifiedColumn, sqlOp, paramIdx), nil default: dc.params = append(dc.params, value) return fmt.Sprintf("%s %s $%d", qualifiedColumn, operator, paramIdx), 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 }