Files
goodie/pkg/orm/domain_parse.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

474 lines
10 KiB
Go

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)
}