package orm import ( "fmt" "strconv" "strings" "unicode" ) // ParseDomainString parses a Python-style domain_force string into an orm.Domain. // Mirrors: odoo/addons/base/models/ir_rule.py safe_eval(domain_force, eval_context) // // Supported syntax: // - Tuples: ('field', 'operator', value) // - Logical operators: '&', '|', '!' // - Values: string literals, numbers, True/False, None, list literals, context variables // - Context variables: user.id, company_id, user.company_id, company_ids // // The env parameter provides runtime context for variable resolution. func ParseDomainString(s string, env *Environment) (Domain, error) { s = strings.TrimSpace(s) if s == "" || s == "[]" { return nil, nil } p := &domainParser{ input: []rune(s), pos: 0, env: env, } return p.parseDomain() } // domainParser is a simple recursive-descent parser for Python domain expressions. type domainParser struct { input []rune pos int env *Environment } func (p *domainParser) parseDomain() (Domain, error) { p.skipWhitespace() if p.pos >= len(p.input) { return nil, nil } if p.input[p.pos] != '[' { return nil, fmt.Errorf("domain_parse: expected '[' at position %d, got %c", p.pos, p.input[p.pos]) } p.pos++ // consume '[' var nodes []DomainNode for { p.skipWhitespace() if p.pos >= len(p.input) { return nil, fmt.Errorf("domain_parse: unexpected end of input") } if p.input[p.pos] == ']' { p.pos++ // consume ']' break } // Skip commas between elements if p.input[p.pos] == ',' { p.pos++ continue } node, err := p.parseNode() if err != nil { return nil, err } nodes = append(nodes, node) } // Convert the list of nodes into a proper Domain. // Odoo domains are in prefix (Polish) notation: // ['&', (a), (b)] means a AND b // If no explicit operator prefix, Odoo implicitly ANDs consecutive leaves. return normalizeDomainNodes(nodes), nil } // normalizeDomainNodes adds implicit '&' operators between consecutive leaf nodes // that don't have an explicit operator, mirroring Odoo's behavior. func normalizeDomainNodes(nodes []DomainNode) Domain { if len(nodes) == 0 { return nil } // Check if the domain already has operators in prefix position. // If first node is an operator, assume the domain is already in Polish notation. if _, isOp := nodes[0].(Operator); isOp { return Domain(nodes) } // No prefix operators: implicitly AND all leaf conditions. if len(nodes) == 1 { return Domain{nodes[0]} } // Multiple leaves without operators: AND them together. return And(nodes...) } func (p *domainParser) parseNode() (DomainNode, error) { p.skipWhitespace() if p.pos >= len(p.input) { return nil, fmt.Errorf("domain_parse: unexpected end of input") } ch := p.input[p.pos] // Check for logical operators: '&', '|', '!' if ch == '\'' || ch == '"' { // Could be a string operator like '&' or '|' or '!' str, err := p.parseString() if err != nil { return nil, err } switch str { case "&": return OpAnd, nil case "|": return OpOr, nil case "!": return OpNot, nil default: return nil, fmt.Errorf("domain_parse: unexpected string %q where operator or tuple expected", str) } } // Check for tuple: (field, operator, value) if ch == '(' { return p.parseTuple() } return nil, fmt.Errorf("domain_parse: unexpected character %c at position %d", ch, p.pos) } func (p *domainParser) parseTuple() (DomainNode, error) { if p.input[p.pos] != '(' { return nil, fmt.Errorf("domain_parse: expected '(' at position %d", p.pos) } p.pos++ // consume '(' // Parse field name (string) p.skipWhitespace() field, err := p.parseString() if err != nil { return nil, fmt.Errorf("domain_parse: field name: %w", err) } p.skipWhitespace() p.expectChar(',') // Parse operator (string) p.skipWhitespace() operator, err := p.parseString() if err != nil { return nil, fmt.Errorf("domain_parse: operator: %w", err) } p.skipWhitespace() p.expectChar(',') // Parse value p.skipWhitespace() value, err := p.parseValue() if err != nil { return nil, fmt.Errorf("domain_parse: value for (%s, %s, ...): %w", field, operator, err) } p.skipWhitespace() if p.pos < len(p.input) && p.input[p.pos] == ')' { p.pos++ // consume ')' } else { return nil, fmt.Errorf("domain_parse: expected ')' at position %d", p.pos) } return Condition{Field: field, Operator: operator, Value: value}, nil } func (p *domainParser) parseValue() (Value, error) { p.skipWhitespace() if p.pos >= len(p.input) { return nil, fmt.Errorf("domain_parse: unexpected end of input in value") } ch := p.input[p.pos] // String literal if ch == '\'' || ch == '"' { return p.parseString() } // List literal if ch == '[' { return p.parseList() } // Tuple literal used as list value (some domain_force uses tuple syntax) if ch == '(' { return p.parseTupleAsList() } // Number or negative number if ch == '-' || (ch >= '0' && ch <= '9') { return p.parseNumber() } // Keywords or context variables if unicode.IsLetter(ch) || ch == '_' { return p.parseIdentOrKeyword() } return nil, fmt.Errorf("domain_parse: unexpected character %c at position %d in value", ch, p.pos) } func (p *domainParser) parseString() (string, error) { if p.pos >= len(p.input) { return "", fmt.Errorf("domain_parse: unexpected end of input in string") } quote := p.input[p.pos] if quote != '\'' && quote != '"' { return "", fmt.Errorf("domain_parse: expected quote at position %d, got %c", p.pos, quote) } p.pos++ // consume opening quote var sb strings.Builder for p.pos < len(p.input) { ch := p.input[p.pos] if ch == '\\' && p.pos+1 < len(p.input) { p.pos++ sb.WriteRune(p.input[p.pos]) p.pos++ continue } if ch == quote { p.pos++ // consume closing quote return sb.String(), nil } sb.WriteRune(ch) p.pos++ } return "", fmt.Errorf("domain_parse: unterminated string starting at position %d", p.pos) } func (p *domainParser) parseNumber() (Value, error) { start := p.pos if p.input[p.pos] == '-' { p.pos++ } isFloat := false for p.pos < len(p.input) { ch := p.input[p.pos] if ch == '.' && !isFloat { isFloat = true p.pos++ continue } if ch >= '0' && ch <= '9' { p.pos++ continue } break } numStr := string(p.input[start:p.pos]) if isFloat { f, err := strconv.ParseFloat(numStr, 64) if err != nil { return nil, fmt.Errorf("domain_parse: invalid float %q: %w", numStr, err) } return f, nil } n, err := strconv.ParseInt(numStr, 10, 64) if err != nil { return nil, fmt.Errorf("domain_parse: invalid integer %q: %w", numStr, err) } return n, nil } func (p *domainParser) parseList() (Value, error) { if p.input[p.pos] != '[' { return nil, fmt.Errorf("domain_parse: expected '[' at position %d", p.pos) } p.pos++ // consume '[' var items []interface{} for { p.skipWhitespace() if p.pos >= len(p.input) { return nil, fmt.Errorf("domain_parse: unterminated list") } if p.input[p.pos] == ']' { p.pos++ // consume ']' break } if p.input[p.pos] == ',' { p.pos++ continue } val, err := p.parseValue() if err != nil { return nil, err } items = append(items, val) } // Try to produce typed slices for common cases. return normalizeListValue(items), nil } // parseTupleAsList parses a Python tuple literal (1, 2, 3) as a list value. func (p *domainParser) parseTupleAsList() (Value, error) { if p.input[p.pos] != '(' { return nil, fmt.Errorf("domain_parse: expected '(' at position %d", p.pos) } p.pos++ // consume '(' var items []interface{} for { p.skipWhitespace() if p.pos >= len(p.input) { return nil, fmt.Errorf("domain_parse: unterminated tuple-as-list") } if p.input[p.pos] == ')' { p.pos++ // consume ')' break } if p.input[p.pos] == ',' { p.pos++ continue } val, err := p.parseValue() if err != nil { return nil, err } items = append(items, val) } return normalizeListValue(items), nil } // normalizeListValue converts []interface{} to typed slices when all elements // share the same type, for compatibility with normalizeSlice in domain compilation. func normalizeListValue(items []interface{}) interface{} { if len(items) == 0 { return []int64{} } // Check if all items are int64 allInt := true for _, v := range items { if _, ok := v.(int64); !ok { allInt = false break } } if allInt { result := make([]int64, len(items)) for i, v := range items { result[i] = v.(int64) } return result } // Check if all items are strings allStr := true for _, v := range items { if _, ok := v.(string); !ok { allStr = false break } } if allStr { result := make([]string, len(items)) for i, v := range items { result[i] = v.(string) } return result } return items } func (p *domainParser) parseIdentOrKeyword() (Value, error) { start := p.pos for p.pos < len(p.input) { ch := p.input[p.pos] if unicode.IsLetter(ch) || unicode.IsDigit(ch) || ch == '_' || ch == '.' { p.pos++ } else { break } } ident := string(p.input[start:p.pos]) switch ident { case "True": return true, nil case "False": return false, nil case "None": return nil, nil // Context variables from _eval_context case "user.id": if p.env != nil { return p.env.UID(), nil } return int64(0), nil case "company_id", "user.company_id": if p.env != nil { return p.env.CompanyID(), nil } return int64(0), nil case "company_ids": if p.env != nil { return []int64{p.env.CompanyID()}, nil } return []int64{}, nil } // Handle dotted identifiers that start with known prefixes. // e.g., user.company_id.id, user.partner_id.id, etc. if strings.HasPrefix(ident, "user.") { // For now, resolve common patterns. Unknown paths return 0/nil. switch ident { case "user.company_id.id": if p.env != nil { return p.env.CompanyID(), nil } return int64(0), nil case "user.company_ids.ids": if p.env != nil { return []int64{p.env.CompanyID()}, nil } return []int64{}, nil default: // Unknown user attribute: return 0 as safe fallback. return int64(0), nil } } return nil, fmt.Errorf("domain_parse: unknown identifier %q at position %d", ident, start) } func (p *domainParser) skipWhitespace() { for p.pos < len(p.input) && unicode.IsSpace(p.input[p.pos]) { p.pos++ } } func (p *domainParser) expectChar(ch rune) { p.skipWhitespace() if p.pos < len(p.input) && p.input[p.pos] == ch { p.pos++ } // Tolerate missing comma (lenient parsing) }