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:
Marc
2026-03-31 01:45:09 +02:00
commit 0ed29fe2fd
90 changed files with 12133 additions and 0 deletions

796
pkg/orm/recordset.go Normal file
View File

@@ -0,0 +1,796 @@
package orm
import (
"fmt"
"strings"
)
// Recordset represents an ordered set of records for a model.
// Mirrors: odoo/orm/models.py BaseModel (which IS the recordset)
//
// In Odoo, a model instance IS a recordset. Every operation returns recordsets:
//
// partners = self.env['res.partner'].search([('name', 'ilike', 'test')])
// for partner in partners:
// print(partner.name)
//
// Go equivalent:
//
// partners := env.Model("res.partner").Search(And(Leaf("name", "ilike", "test")))
// for _, rec := range partners.Records() {
// fmt.Println(rec.Get("name"))
// }
type Recordset struct {
env *Environment
model *Model
ids []int64
}
// Env returns the environment of this recordset.
func (rs *Recordset) Env() *Environment { return rs.env }
// Model returns the model of this recordset.
func (rs *Recordset) ModelDef() *Model { return rs.model }
// IDs returns the record IDs in this set.
func (rs *Recordset) IDs() []int64 { return rs.ids }
// Len returns the number of records.
func (rs *Recordset) Len() int { return len(rs.ids) }
// IsEmpty returns true if no records.
func (rs *Recordset) IsEmpty() bool { return len(rs.ids) == 0 }
// Ensure checks that this recordset contains exactly one record.
// Mirrors: odoo.models.BaseModel.ensure_one()
func (rs *Recordset) Ensure() error {
if len(rs.ids) != 1 {
return fmt.Errorf("orm: expected singleton, got %d records for %s", len(rs.ids), rs.model.name)
}
return nil
}
// ID returns the ID of a singleton recordset. Panics if not singleton.
func (rs *Recordset) ID() int64 {
if err := rs.Ensure(); err != nil {
panic(err)
}
return rs.ids[0]
}
// Browse returns a recordset for the given IDs.
// Mirrors: self.env['model'].browse([1, 2, 3])
func (rs *Recordset) Browse(ids ...int64) *Recordset {
return &Recordset{
env: rs.env,
model: rs.model,
ids: ids,
}
}
// Sudo returns this recordset with superuser privileges.
// Mirrors: records.sudo()
func (rs *Recordset) Sudo() *Recordset {
return &Recordset{
env: rs.env.Sudo(),
model: rs.model,
ids: rs.ids,
}
}
// WithContext returns this recordset with additional context.
func (rs *Recordset) WithContext(ctx map[string]interface{}) *Recordset {
return &Recordset{
env: rs.env.WithContext(ctx),
model: rs.model,
ids: rs.ids,
}
}
// --- CRUD Operations ---
// Create creates a new record and returns a recordset containing it.
// Mirrors: self.env['model'].create(vals)
func (rs *Recordset) Create(vals Values) (*Recordset, error) {
m := rs.model
// Phase 1: Apply defaults for missing fields
ApplyDefaults(m, vals)
// Add magic fields
if rs.env.uid > 0 {
vals["create_uid"] = rs.env.uid
vals["write_uid"] = rs.env.uid
}
// Phase 2: BeforeCreate hook (e.g., sequence generation)
if m.BeforeCreate != nil {
if err := m.BeforeCreate(rs.env, vals); err != nil {
return nil, err
}
}
// Phase 1: Validate required fields
if err := ValidateRequired(m, vals, true); err != nil {
return nil, err
}
// Build INSERT statement
var columns []string
var placeholders []string
var args []interface{}
idx := 1
for _, name := range m.fieldOrder {
f := m.fields[name]
if name == "id" || !f.IsStored() {
continue
}
val, exists := vals[name]
if !exists {
continue
}
columns = append(columns, fmt.Sprintf("%q", f.Column()))
placeholders = append(placeholders, fmt.Sprintf("$%d", idx))
args = append(args, val)
idx++
}
if len(columns) == 0 {
// Create with defaults only
columns = append(columns, `"create_date"`)
placeholders = append(placeholders, "NOW()")
}
query := fmt.Sprintf(
`INSERT INTO %q (%s) VALUES (%s) RETURNING "id"`,
m.table,
strings.Join(columns, ", "),
strings.Join(placeholders, ", "),
)
var id int64
err := rs.env.tx.QueryRow(rs.env.ctx, query, args...).Scan(&id)
if err != nil {
return nil, fmt.Errorf("orm: create %s: %w", m.name, err)
}
// Invalidate cache for this model
rs.env.cache.Invalidate(m.name)
// Process relational field commands (O2M/M2M)
if err := processRelationalCommands(rs.env, m, id, vals); err != nil {
return nil, err
}
// Run stored computed fields (after children exist for O2M-based computes)
if err := RunStoredComputes(m, rs.env, id, vals); err != nil {
return nil, err
}
// Write any newly computed values to the record
computedOnly := make(Values)
for _, name := range m.fieldOrder {
f := m.fields[name]
if f.Compute != "" && f.Store {
if v, ok := vals[name]; ok {
computedOnly[name] = v
}
}
}
if len(computedOnly) > 0 {
if err := writeDirectNohook(rs.env, m, id, computedOnly); err != nil {
return nil, err
}
}
result := rs.Browse(id)
// Run constraints after record is fully created (with children + computes)
for _, constraint := range m.Constraints {
if err := constraint(result); err != nil {
return nil, err
}
}
return result, nil
}
// Write updates the records in this recordset.
// Mirrors: records.write(vals)
func (rs *Recordset) Write(vals Values) error {
if len(rs.ids) == 0 {
return nil
}
m := rs.model
var setClauses []string
var args []interface{}
idx := 1
// Add write metadata
if rs.env.uid > 0 {
vals["write_uid"] = rs.env.uid
}
vals["write_date"] = "NOW()" // Will be handled specially
for _, name := range m.fieldOrder {
f := m.fields[name]
if name == "id" || !f.IsStored() {
continue
}
val, exists := vals[name]
if !exists {
continue
}
if name == "write_date" {
setClauses = append(setClauses, `"write_date" = NOW()`)
continue
}
setClauses = append(setClauses, fmt.Sprintf("%q = $%d", f.Column(), idx))
args = append(args, val)
idx++
}
if len(setClauses) == 0 {
return nil
}
// Build WHERE clause for IDs
idPlaceholders := make([]string, len(rs.ids))
for i, id := range rs.ids {
args = append(args, id)
idPlaceholders[i] = fmt.Sprintf("$%d", idx)
idx++
}
query := fmt.Sprintf(
`UPDATE %q SET %s WHERE "id" IN (%s)`,
m.table,
strings.Join(setClauses, ", "),
strings.Join(idPlaceholders, ", "),
)
_, err := rs.env.tx.Exec(rs.env.ctx, query, args...)
if err != nil {
return fmt.Errorf("orm: write %s: %w", m.name, err)
}
// Invalidate cache
for _, id := range rs.ids {
rs.env.cache.InvalidateRecord(m.name, id)
}
// Process relational field commands (O2M/M2M) for each record
for _, id := range rs.ids {
if err := processRelationalCommands(rs.env, m, id, vals); err != nil {
return err
}
}
// Trigger recompute for stored computed fields that depend on written fields
if err := TriggerRecompute(rs, vals); err != nil {
return err
}
return nil
}
// Unlink deletes the records in this recordset.
// Mirrors: records.unlink()
func (rs *Recordset) Unlink() error {
if len(rs.ids) == 0 {
return nil
}
m := rs.model
var args []interface{}
placeholders := make([]string, len(rs.ids))
for i, id := range rs.ids {
args = append(args, id)
placeholders[i] = fmt.Sprintf("$%d", i+1)
}
query := fmt.Sprintf(
`DELETE FROM %q WHERE "id" IN (%s)`,
m.table,
strings.Join(placeholders, ", "),
)
_, err := rs.env.tx.Exec(rs.env.ctx, query, args...)
if err != nil {
return fmt.Errorf("orm: unlink %s: %w", m.name, err)
}
// Invalidate cache
for _, id := range rs.ids {
rs.env.cache.InvalidateRecord(m.name, id)
}
return nil
}
// --- Read Operations ---
// Read reads field values for the records in this recordset.
// Mirrors: records.read(['field1', 'field2'])
func (rs *Recordset) Read(fields []string) ([]Values, error) {
if len(rs.ids) == 0 {
return nil, nil
}
m := rs.model
// Resolve fields to column names
if len(fields) == 0 {
// Read all stored fields
for _, name := range m.fieldOrder {
f := m.fields[name]
if f.IsStored() {
fields = append(fields, name)
}
}
}
var columns []string
var storedFields []string // Fields that come from the DB query
var m2mFields []string // Many2many fields (from junction table)
var relatedFields []string // Related fields (from joined table)
for _, name := range fields {
f := m.GetField(name)
if f == nil {
return nil, fmt.Errorf("orm: field %q not found on %s", name, m.name)
}
if f.Type == TypeMany2many {
m2mFields = append(m2mFields, name)
} else if f.Related != "" && !f.Store {
relatedFields = append(relatedFields, name)
} else if f.IsStored() {
columns = append(columns, fmt.Sprintf("%q", f.Column()))
storedFields = append(storedFields, name)
}
}
// Build query
var args []interface{}
idPlaceholders := make([]string, len(rs.ids))
for i, id := range rs.ids {
args = append(args, id)
idPlaceholders[i] = fmt.Sprintf("$%d", i+1)
}
query := fmt.Sprintf(
`SELECT %s FROM %q WHERE "id" IN (%s) ORDER BY %s`,
strings.Join(columns, ", "),
m.table,
strings.Join(idPlaceholders, ", "),
m.order,
)
rows, err := rs.env.tx.Query(rs.env.ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("orm: read %s: %w", m.name, err)
}
defer rows.Close()
var results []Values
for rows.Next() {
scanDest := make([]interface{}, len(columns))
for i := range scanDest {
scanDest[i] = new(interface{})
}
if err := rows.Scan(scanDest...); err != nil {
return nil, fmt.Errorf("orm: scan %s: %w", m.name, err)
}
record := make(Values, len(fields))
for i, name := range storedFields {
val := *(scanDest[i].(*interface{}))
record[name] = val
// Update cache
if id, ok := record["id"].(int64); ok {
rs.env.cache.Set(m.name, id, name, val)
}
}
results = append(results, record)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Post-fetch: M2M fields (from junction tables)
if len(m2mFields) > 0 && len(rs.ids) > 0 {
for _, fname := range m2mFields {
f := m.GetField(fname)
if f == nil {
continue
}
m2mData, err := ReadM2MField(rs.env, f, rs.ids)
if err != nil {
return nil, fmt.Errorf("orm: read M2M %s.%s: %w", m.name, fname, err)
}
for _, rec := range results {
if id, ok := rec["id"].(int64); ok {
rec[fname] = m2mData[id]
} else if id, ok := rec["id"].(int32); ok {
rec[fname] = m2mData[int64(id)]
}
}
}
}
// Post-fetch: Related fields (follow M2O chain)
if len(relatedFields) > 0 {
for _, fname := range relatedFields {
f := m.GetField(fname)
if f == nil || f.Related == "" {
continue
}
parts := strings.Split(f.Related, ".")
if len(parts) != 2 {
continue // Only support single-hop related for now
}
fkField := parts[0]
targetField := parts[1]
fkDef := m.GetField(fkField)
if fkDef == nil || fkDef.Type != TypeMany2one {
continue
}
// Collect FK IDs from results
fkIDs := make(map[int64]bool)
for _, rec := range results {
if fkID, ok := toRecordID(rec[fkField]); ok && fkID > 0 {
fkIDs[fkID] = true
}
}
if len(fkIDs) == 0 {
for _, rec := range results {
rec[fname] = nil
}
continue
}
// Fetch related values
var ids []int64
for id := range fkIDs {
ids = append(ids, id)
}
comodelRS := rs.env.Model(fkDef.Comodel).Browse(ids...)
relatedData, err := comodelRS.Read([]string{"id", targetField})
if err != nil {
continue // Skip on error
}
lookup := make(map[int64]interface{})
for _, rd := range relatedData {
if id, ok := toRecordID(rd["id"]); ok {
lookup[id] = rd[targetField]
}
}
for _, rec := range results {
if fkID, ok := toRecordID(rec[fkField]); ok {
rec[fname] = lookup[fkID]
} else {
rec[fname] = nil
}
}
}
}
return results, nil
}
// Get reads a single field value from a singleton record.
// Mirrors: record.field_name (Python attribute access)
func (rs *Recordset) Get(field string) (Value, error) {
if err := rs.Ensure(); err != nil {
return nil, err
}
// Check cache first
if val, ok := rs.env.cache.Get(rs.model.name, rs.ids[0], field); ok {
return val, nil
}
// Read from database
records, err := rs.Read([]string{field})
if err != nil {
return nil, err
}
if len(records) == 0 {
return nil, fmt.Errorf("orm: record %s(%d) not found", rs.model.name, rs.ids[0])
}
return records[0][field], nil
}
// --- Search Operations ---
// Search searches for records matching the domain.
// Mirrors: self.env['model'].search(domain, offset=0, limit=None, order=None)
func (rs *Recordset) Search(domain Domain, opts ...SearchOpts) (*Recordset, error) {
m := rs.model
opt := mergeSearchOpts(opts)
// Apply record rules (e.g., multi-company filter)
domain = ApplyRecordRules(rs.env, m, domain)
// Compile domain to SQL
compiler := &DomainCompiler{model: m}
where, params, err := compiler.Compile(domain)
if err != nil {
return nil, fmt.Errorf("orm: search %s: %w", m.name, err)
}
// Build query
order := m.order
if opt.Order != "" {
order = opt.Order
}
joinSQL := compiler.JoinSQL()
// Qualify ORDER BY columns with table name when JOINs are present
qualifiedOrder := order
if joinSQL != "" {
qualifiedOrder = qualifyOrderBy(m.table, order)
}
query := fmt.Sprintf(
`SELECT %q."id" FROM %q%s WHERE %s ORDER BY %s`,
m.table, m.table, joinSQL, where, qualifiedOrder,
)
if opt.Limit > 0 {
query += fmt.Sprintf(" LIMIT %d", opt.Limit)
}
if opt.Offset > 0 {
query += fmt.Sprintf(" OFFSET %d", opt.Offset)
}
rows, err := rs.env.tx.Query(rs.env.ctx, query, params...)
if err != nil {
return nil, fmt.Errorf("orm: search %s: %w", m.name, err)
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("orm: search scan %s: %w", m.name, err)
}
ids = append(ids, id)
}
return rs.Browse(ids...), rows.Err()
}
// SearchCount returns the number of records matching the domain.
// Mirrors: self.env['model'].search_count(domain)
func (rs *Recordset) SearchCount(domain Domain) (int64, error) {
m := rs.model
compiler := &DomainCompiler{model: m}
where, params, err := compiler.Compile(domain)
if err != nil {
return 0, fmt.Errorf("orm: search_count %s: %w", m.name, err)
}
joinSQL := compiler.JoinSQL()
query := fmt.Sprintf(`SELECT COUNT(*) FROM %q%s WHERE %s`, m.table, joinSQL, where)
var count int64
err = rs.env.tx.QueryRow(rs.env.ctx, query, params...).Scan(&count)
if err != nil {
return 0, fmt.Errorf("orm: search_count %s: %w", m.name, err)
}
return count, nil
}
// SearchRead combines search and read in one call.
// Mirrors: self.env['model'].search_read(domain, fields, offset, limit, order)
func (rs *Recordset) SearchRead(domain Domain, fields []string, opts ...SearchOpts) ([]Values, error) {
found, err := rs.Search(domain, opts...)
if err != nil {
return nil, err
}
if found.IsEmpty() {
return nil, nil
}
return found.Read(fields)
}
// NameGet returns display names for the records.
// Mirrors: records.name_get()
func (rs *Recordset) NameGet() (map[int64]string, error) {
if len(rs.ids) == 0 {
return nil, nil
}
recName := rs.model.recName
records, err := rs.Read([]string{"id", recName})
if err != nil {
return nil, err
}
result := make(map[int64]string, len(records))
for _, rec := range records {
id, _ := rec["id"].(int64)
name, _ := rec[recName].(string)
result[id] = name
}
return result, nil
}
// Exists filters this recordset to only records that exist in the database.
// Mirrors: records.exists()
func (rs *Recordset) Exists() (*Recordset, error) {
if len(rs.ids) == 0 {
return rs, nil
}
m := rs.model
var args []interface{}
placeholders := make([]string, len(rs.ids))
for i, id := range rs.ids {
args = append(args, id)
placeholders[i] = fmt.Sprintf("$%d", i+1)
}
query := fmt.Sprintf(
`SELECT "id" FROM %q WHERE "id" IN (%s)`,
m.table,
strings.Join(placeholders, ", "),
)
rows, err := rs.env.tx.Query(rs.env.ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var existing []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
existing = append(existing, id)
}
return rs.Browse(existing...), rows.Err()
}
// Records returns individual singleton recordsets for iteration.
// Mirrors: for record in records: ...
func (rs *Recordset) Records() []*Recordset {
result := make([]*Recordset, len(rs.ids))
for i, id := range rs.ids {
result[i] = rs.Browse(id)
}
return result
}
// Union returns the union of this recordset with others.
// Mirrors: records | other_records
func (rs *Recordset) Union(others ...*Recordset) *Recordset {
seen := make(map[int64]bool)
var ids []int64
for _, id := range rs.ids {
if !seen[id] {
seen[id] = true
ids = append(ids, id)
}
}
for _, other := range others {
for _, id := range other.ids {
if !seen[id] {
seen[id] = true
ids = append(ids, id)
}
}
}
return rs.Browse(ids...)
}
// Subtract returns records in this set but not in the other.
// Mirrors: records - other_records
func (rs *Recordset) Subtract(other *Recordset) *Recordset {
exclude := make(map[int64]bool)
for _, id := range other.ids {
exclude[id] = true
}
var ids []int64
for _, id := range rs.ids {
if !exclude[id] {
ids = append(ids, id)
}
}
return rs.Browse(ids...)
}
// --- Search Options ---
// SearchOpts configures a search operation.
type SearchOpts struct {
Offset int
Limit int
Order string
}
func mergeSearchOpts(opts []SearchOpts) SearchOpts {
if len(opts) == 0 {
return SearchOpts{}
}
return opts[0]
}
// toRecordID extracts an int64 ID from various types PostgreSQL might return.
func toRecordID(v interface{}) (int64, bool) {
switch n := v.(type) {
case int64:
return n, true
case int32:
return int64(n), true
case int:
return int64(n), true
case float64:
return int64(n), true
}
return 0, false
}
// qualifyOrderBy prefixes unqualified column names with the table name.
// "name, id desc" → "\"my_table\".name, \"my_table\".id desc"
func qualifyOrderBy(table, order string) string {
parts := strings.Split(order, ",")
for i, part := range parts {
part = strings.TrimSpace(part)
tokens := strings.Fields(part)
if len(tokens) == 0 {
continue
}
col := tokens[0]
// Skip already qualified columns
if strings.Contains(col, ".") {
continue
}
tokens[0] = fmt.Sprintf("%q.%s", table, col)
parts[i] = strings.Join(tokens, " ")
}
return strings.Join(parts, ", ")
}
// --- Relational Command Processing ---
// processRelationalCommands handles O2M/M2M commands in vals after a Create or Write.
func processRelationalCommands(env *Environment, m *Model, parentID int64, vals Values) error {
for _, name := range m.fieldOrder {
f := m.fields[name]
raw, exists := vals[name]
if !exists {
continue
}
cmds, ok := ParseCommands(raw)
if !ok {
continue
}
switch f.Type {
case TypeOne2many:
if err := ProcessO2MCommands(env, f, parentID, cmds); err != nil {
return err
}
case TypeMany2many:
if err := ProcessM2MCommands(env, f, parentID, cmds); err != nil {
return err
}
}
}
return nil
}