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>
378 lines
10 KiB
Go
378 lines
10 KiB
Go
package orm
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// Field defines a model field.
|
|
// Mirrors: odoo/orm/fields.py Field class
|
|
//
|
|
// Odoo field declaration:
|
|
//
|
|
// name = fields.Char(string='Name', required=True, index=True)
|
|
//
|
|
// Go equivalent:
|
|
//
|
|
// Char("name", FieldOpts{String: "Name", Required: true, Index: true})
|
|
type Field struct {
|
|
Name string // Technical name (e.g., "name", "partner_id")
|
|
Type FieldType // Field type (TypeChar, TypeMany2one, etc.)
|
|
String string // Human-readable label
|
|
|
|
// Constraints
|
|
Required bool
|
|
Readonly bool
|
|
Index bool // Create database index
|
|
Unique bool // Unique constraint (not in Odoo, but useful)
|
|
Size int // Max length for Char fields
|
|
Default Value // Default value or function name
|
|
Help string // Tooltip / help text
|
|
|
|
// Computed fields
|
|
// Mirrors: odoo/orm/fields.py Field.compute, Field.inverse, Field.depends
|
|
Compute string // Method name to compute this field
|
|
Inverse string // Method name to write back computed value
|
|
Depends []string // Field names this compute depends on
|
|
Store bool // Store computed value in DB (default: false for computed)
|
|
Precompute bool // Compute before first DB flush
|
|
|
|
// Related fields
|
|
// Mirrors: odoo/orm/fields.py Field.related
|
|
Related string // Dot-separated path (e.g., "partner_id.name")
|
|
|
|
// Selection
|
|
Selection []SelectionItem
|
|
|
|
// Relational fields
|
|
// Mirrors: odoo/orm/fields_relational.py
|
|
Comodel string // Target model name (e.g., "res.company")
|
|
InverseField string // For One2many: field name in comodel pointing back
|
|
Relation string // For Many2many: junction table name
|
|
Column1 string // For Many2many: column name for this model's FK
|
|
Column2 string // For Many2many: column name for comodel's FK
|
|
Domain Domain // Default domain filter for relational fields
|
|
OnDelete OnDelete // For Many2one: what happens on target deletion
|
|
|
|
// Monetary
|
|
CurrencyField string // Field name holding the currency_id
|
|
|
|
// Translation
|
|
Translate bool // Field supports multi-language
|
|
|
|
// Groups (access control)
|
|
Groups string // Comma-separated group XML IDs
|
|
|
|
// Internal
|
|
model *Model // Back-reference to owning model
|
|
column string // SQL column name (usually same as Name)
|
|
}
|
|
|
|
// Column returns the SQL column name for this field.
|
|
func (f *Field) Column() string {
|
|
if f.column != "" {
|
|
return f.column
|
|
}
|
|
return f.Name
|
|
}
|
|
|
|
// SQLType returns the PostgreSQL type for this field.
|
|
func (f *Field) SQLType() string {
|
|
if f.Type == TypeChar && f.Size > 0 {
|
|
return fmt.Sprintf("varchar(%d)", f.Size)
|
|
}
|
|
return f.Type.SQLType()
|
|
}
|
|
|
|
// IsStored returns true if this field has a database column.
|
|
func (f *Field) IsStored() bool {
|
|
// Computed fields without Store are not stored
|
|
if f.Compute != "" && !f.Store {
|
|
return false
|
|
}
|
|
// Related fields without Store are not stored
|
|
if f.Related != "" && !f.Store {
|
|
return false
|
|
}
|
|
return f.Type.IsStored()
|
|
}
|
|
|
|
// IsRelational returns true for relational field types.
|
|
func (f *Field) IsRelational() bool {
|
|
return f.Type.IsRelational()
|
|
}
|
|
|
|
// --- Field constructors ---
|
|
// Mirror Odoo's fields.Char(), fields.Integer(), etc.
|
|
|
|
// FieldOpts holds optional parameters for field constructors.
|
|
type FieldOpts struct {
|
|
String string
|
|
Required bool
|
|
Readonly bool
|
|
Index bool
|
|
Size int
|
|
Default Value
|
|
Help string
|
|
Compute string
|
|
Inverse string
|
|
Depends []string
|
|
Store bool
|
|
Precompute bool
|
|
Related string
|
|
Selection []SelectionItem
|
|
Comodel string
|
|
InverseField string
|
|
Relation string
|
|
Column1 string
|
|
Column2 string
|
|
Domain Domain
|
|
OnDelete OnDelete
|
|
CurrencyField string
|
|
Translate bool
|
|
Groups string
|
|
}
|
|
|
|
func newField(name string, typ FieldType, opts FieldOpts) *Field {
|
|
f := &Field{
|
|
Name: name,
|
|
Type: typ,
|
|
String: opts.String,
|
|
Required: opts.Required,
|
|
Readonly: opts.Readonly,
|
|
Index: opts.Index,
|
|
Size: opts.Size,
|
|
Default: opts.Default,
|
|
Help: opts.Help,
|
|
Compute: opts.Compute,
|
|
Inverse: opts.Inverse,
|
|
Depends: opts.Depends,
|
|
Store: opts.Store,
|
|
Precompute: opts.Precompute,
|
|
Related: opts.Related,
|
|
Selection: opts.Selection,
|
|
Comodel: opts.Comodel,
|
|
InverseField: opts.InverseField,
|
|
Relation: opts.Relation,
|
|
Column1: opts.Column1,
|
|
Column2: opts.Column2,
|
|
Domain: opts.Domain,
|
|
OnDelete: opts.OnDelete,
|
|
CurrencyField: opts.CurrencyField,
|
|
Translate: opts.Translate,
|
|
Groups: opts.Groups,
|
|
column: name,
|
|
}
|
|
if f.String == "" {
|
|
f.String = name
|
|
}
|
|
return f
|
|
}
|
|
|
|
// Char creates a character field. Mirrors: fields.Char
|
|
func Char(name string, opts FieldOpts) *Field {
|
|
return newField(name, TypeChar, opts)
|
|
}
|
|
|
|
// Text creates a text field. Mirrors: fields.Text
|
|
func Text(name string, opts FieldOpts) *Field {
|
|
return newField(name, TypeText, opts)
|
|
}
|
|
|
|
// HTML creates an HTML field. Mirrors: fields.Html
|
|
func HTML(name string, opts FieldOpts) *Field {
|
|
return newField(name, TypeHTML, opts)
|
|
}
|
|
|
|
// Integer creates an integer field. Mirrors: fields.Integer
|
|
func Integer(name string, opts FieldOpts) *Field {
|
|
return newField(name, TypeInteger, opts)
|
|
}
|
|
|
|
// Float creates a float field. Mirrors: fields.Float
|
|
func Float(name string, opts FieldOpts) *Field {
|
|
return newField(name, TypeFloat, opts)
|
|
}
|
|
|
|
// Monetary creates a monetary field. Mirrors: fields.Monetary
|
|
func Monetary(name string, opts FieldOpts) *Field {
|
|
return newField(name, TypeMonetary, opts)
|
|
}
|
|
|
|
// Boolean creates a boolean field. Mirrors: fields.Boolean
|
|
func Boolean(name string, opts FieldOpts) *Field {
|
|
return newField(name, TypeBoolean, opts)
|
|
}
|
|
|
|
// Date creates a date field. Mirrors: fields.Date
|
|
func Date(name string, opts FieldOpts) *Field {
|
|
return newField(name, TypeDate, opts)
|
|
}
|
|
|
|
// Datetime creates a datetime field. Mirrors: fields.Datetime
|
|
func Datetime(name string, opts FieldOpts) *Field {
|
|
return newField(name, TypeDatetime, opts)
|
|
}
|
|
|
|
// Binary creates a binary field. Mirrors: fields.Binary
|
|
func Binary(name string, opts FieldOpts) *Field {
|
|
return newField(name, TypeBinary, opts)
|
|
}
|
|
|
|
// Selection creates a selection field. Mirrors: fields.Selection
|
|
func Selection(name string, selection []SelectionItem, opts FieldOpts) *Field {
|
|
opts.Selection = selection
|
|
return newField(name, TypeSelection, opts)
|
|
}
|
|
|
|
// Json creates a JSON field. Mirrors: fields.Json
|
|
func Json(name string, opts FieldOpts) *Field {
|
|
return newField(name, TypeJson, opts)
|
|
}
|
|
|
|
// Many2one creates a many-to-one relational field. Mirrors: fields.Many2one
|
|
func Many2one(name string, comodel string, opts FieldOpts) *Field {
|
|
opts.Comodel = comodel
|
|
if opts.OnDelete == "" {
|
|
opts.OnDelete = OnDeleteSetNull
|
|
}
|
|
f := newField(name, TypeMany2one, opts)
|
|
f.Index = true // M2O fields are always indexed in Odoo
|
|
return f
|
|
}
|
|
|
|
// One2many creates a one-to-many relational field. Mirrors: fields.One2many
|
|
func One2many(name string, comodel string, inverseField string, opts FieldOpts) *Field {
|
|
opts.Comodel = comodel
|
|
opts.InverseField = inverseField
|
|
return newField(name, TypeOne2many, opts)
|
|
}
|
|
|
|
// Many2many creates a many-to-many relational field. Mirrors: fields.Many2many
|
|
func Many2many(name string, comodel string, opts FieldOpts) *Field {
|
|
opts.Comodel = comodel
|
|
return newField(name, TypeMany2many, opts)
|
|
}
|
|
|
|
// --- Default & Validation Helpers ---
|
|
|
|
// ResolveDefault returns the concrete default value for this field.
|
|
// Handles: literal values, "today" sentinel for date/datetime fields.
|
|
func (f *Field) ResolveDefault() Value {
|
|
if f.Default == nil {
|
|
return nil
|
|
}
|
|
|
|
switch v := f.Default.(type) {
|
|
case string:
|
|
if v == "today" && (f.Type == TypeDate || f.Type == TypeDatetime) {
|
|
return time.Now().Format("2006-01-02")
|
|
}
|
|
return v
|
|
case bool, int, int64, float64:
|
|
return v
|
|
default:
|
|
return v
|
|
}
|
|
}
|
|
|
|
// ApplyDefaults sets default values on vals for any stored field that is
|
|
// missing from vals and has a non-nil Default.
|
|
// Mirrors: odoo/orm/models.py BaseModel._add_missing_default_values()
|
|
func ApplyDefaults(m *Model, vals Values) {
|
|
for _, name := range m.fieldOrder {
|
|
f := m.fields[name]
|
|
if name == "id" || !f.IsStored() || f.Default == nil {
|
|
continue
|
|
}
|
|
if _, exists := vals[name]; exists {
|
|
continue
|
|
}
|
|
if resolved := f.ResolveDefault(); resolved != nil {
|
|
vals[name] = resolved
|
|
}
|
|
}
|
|
}
|
|
|
|
// ValidateRequired checks that all required stored fields have a non-nil value in vals.
|
|
// Returns an error describing the first missing required field.
|
|
// Mirrors: odoo/orm/models.py BaseModel._check_required()
|
|
func ValidateRequired(m *Model, vals Values, isCreate bool) error {
|
|
for _, name := range m.fieldOrder {
|
|
f := m.fields[name]
|
|
if !f.Required || !f.IsStored() || name == "id" {
|
|
continue
|
|
}
|
|
// Magic fields are auto-set
|
|
if name == "create_uid" || name == "write_uid" || name == "create_date" || name == "write_date" {
|
|
continue
|
|
}
|
|
if isCreate {
|
|
val, exists := vals[name]
|
|
if !exists || val == nil {
|
|
return fmt.Errorf("orm: field '%s' is required on %s", name, m.Name())
|
|
}
|
|
}
|
|
// On write: only check if the field is explicitly set to nil
|
|
if !isCreate {
|
|
val, exists := vals[name]
|
|
if exists && val == nil {
|
|
return fmt.Errorf("orm: field '%s' cannot be set to null on %s (required)", name, m.Name())
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// JunctionTable returns the M2M junction table name for this field.
|
|
func (f *Field) JunctionTable() string {
|
|
if f.Relation != "" {
|
|
return f.Relation
|
|
}
|
|
if f.model == nil || f.Comodel == "" {
|
|
return ""
|
|
}
|
|
comodel := Registry.Get(f.Comodel)
|
|
if comodel == nil {
|
|
return ""
|
|
}
|
|
t1, t2 := f.model.Table(), comodel.Table()
|
|
if t1 > t2 {
|
|
t1, t2 = t2, t1
|
|
}
|
|
return fmt.Sprintf("%s_%s_rel", t1, t2)
|
|
}
|
|
|
|
// JunctionCol1 returns this model's FK column in the junction table.
|
|
func (f *Field) JunctionCol1() string {
|
|
if f.Column1 != "" {
|
|
return f.Column1
|
|
}
|
|
if f.model == nil {
|
|
return ""
|
|
}
|
|
col := f.model.Table() + "_id"
|
|
// Self-referential
|
|
comodel := Registry.Get(f.Comodel)
|
|
if comodel != nil && f.model.Table() == comodel.Table() {
|
|
col = f.model.Table() + "_src_id"
|
|
}
|
|
return col
|
|
}
|
|
|
|
// JunctionCol2 returns the comodel's FK column in the junction table.
|
|
func (f *Field) JunctionCol2() string {
|
|
if f.Column2 != "" {
|
|
return f.Column2
|
|
}
|
|
comodel := Registry.Get(f.Comodel)
|
|
if comodel == nil {
|
|
return ""
|
|
}
|
|
col := comodel.Table() + "_id"
|
|
if f.model != nil && f.model.Table() == comodel.Table() {
|
|
col = comodel.Table() + "_dst_id"
|
|
}
|
|
return col
|
|
}
|