Odoo ERP ported to Go — complete backend + original OWL frontend
Full port of Odoo's ERP system from Python to Go, with the original Odoo JavaScript frontend (OWL framework) running against the Go server. Backend (10,691 LoC Go): - Custom ORM: CRUD, domains→SQL with JOINs, computed fields, sequences - 93 models across 14 modules (base, account, sale, stock, purchase, hr, project, crm, fleet, product, l10n_de, google_address/translate/calendar) - Auth with bcrypt + session cookies - Setup wizard (company, SKR03 chart, admin, demo data) - Double-entry bookkeeping constraint - Sale→Invoice workflow (confirm SO → generate invoice → post) - SKR03 chart of accounts (110 accounts) + German taxes (USt/VSt) - Record rules (multi-company filter) - Google integrations as opt-in modules (Maps, Translate, Calendar) Frontend: - Odoo's original OWL webclient (503 JS modules, 378 XML templates) - JS transpiled via Odoo's js_transpiler (ES modules → odoo.define) - SCSS compiled to CSS (675KB) via dart-sass - XML templates compiled to registerTemplate() JS calls - Static file serving from Odoo source addons - Login page, session management, menu navigation - Contacts list view renders with real data from PostgreSQL Infrastructure: - 14MB single binary (CGO_ENABLED=0) - Docker Compose (Go server + PostgreSQL 16) - Zero phone-home (no outbound calls to odoo.com) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
429
pkg/orm/domain.go
Normal file
429
pkg/orm/domain.go
Normal file
@@ -0,0 +1,429 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user