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>
474 lines
10 KiB
Go
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)
|
|
}
|