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>
This commit is contained in:
473
pkg/orm/domain_parse.go
Normal file
473
pkg/orm/domain_parse.go
Normal file
@@ -0,0 +1,473 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user