Files
goodie/pkg/orm/model.go
Marc 2e5a550069 Add _inherit (ExtendModel) + Inverse fields + sale extends partner
ORM:
- ExtendModel(name) retrieves existing model for extension (mirrors
  Python _inherit without _name). Panics on missing model.
- RegisterInverse(fieldName, fn) convenience for computed write-back
- Inverse field handling in Write(): caches new value, calls inverse
  method so computed fields can be written back

Sale module:
- Extends res.partner with sale_order_ids (O2M) and sale_order_count
  (computed) via ExtendModel — demonstrates real _inherit pattern

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:23:40 +02:00

452 lines
12 KiB
Go

package orm
import (
"fmt"
"strings"
)
// Model defines an Odoo model (database table + business logic).
// Mirrors: odoo/orm/models.py BaseModel
//
// Odoo:
//
// class ResPartner(models.Model):
// _name = 'res.partner'
// _description = 'Contact'
// _order = 'name'
// _rec_name = 'name'
//
// Go:
//
// NewModel("res.partner", ModelOpts{
// Description: "Contact",
// Order: "name",
// RecName: "name",
// })
type Model struct {
name string
description string
modelType ModelType
table string // SQL table name
order string // Default sort order
recName string // Field used as display name
inherit []string // _inherit: models to extend
inherits map[string]string // _inherits: {parent_model: fk_field}
auto bool // Auto-create/update table schema
logAccess bool // Auto-create create_uid, write_uid, create_date, write_date
// Fields
fields map[string]*Field
// Methods (registered business logic)
methods map[string]interface{}
// Access control
checkCompany bool // Enforce multi-company record rules
// Hooks
BeforeCreate func(env *Environment, vals Values) error // Called before INSERT
DefaultGet func(env *Environment, fields []string) Values // Dynamic defaults (e.g., from DB)
Constraints []ConstraintFunc // Validation constraints
Methods map[string]MethodFunc // Named business methods
// SQL-level constraints
SQLConstraints []SQLConstraint
// Computed fields
computes map[string]ComputeFunc // field_name → compute function
dependencyMap map[string][]string // trigger_field → []computed_field_names
// Onchange handlers
// Maps field_name → handler that receives current vals and returns computed updates.
// Mirrors: @api.onchange in Odoo.
OnchangeHandlers map[string]func(env *Environment, vals Values) Values
// Resolved
parents []*Model // Resolved parent models from _inherit
allFields map[string]*Field // Including fields from parents
fieldOrder []string // Field creation order
}
// ModelOpts configures a new model.
type ModelOpts struct {
Description string
Type ModelType
Table string
Order string
RecName string
Inherit []string
Inherits map[string]string
CheckCompany bool
}
// NewModel creates and registers a new model.
// Mirrors: class MyModel(models.Model): _name = '...'
func NewModel(name string, opts ModelOpts) *Model {
m := &Model{
name: name,
description: opts.Description,
modelType: opts.Type,
table: opts.Table,
order: opts.Order,
recName: opts.RecName,
inherit: opts.Inherit,
inherits: opts.Inherits,
checkCompany: opts.CheckCompany,
auto: opts.Type != ModelAbstract,
logAccess: true,
fields: make(map[string]*Field),
methods: make(map[string]interface{}),
allFields: make(map[string]*Field),
}
// Derive table name from model name if not specified.
// Mirrors: odoo/orm/models.py BaseModel._table
// 'res.partner' → 'res_partner'
if m.table == "" && m.auto {
m.table = strings.ReplaceAll(name, ".", "_")
}
if m.order == "" {
m.order = "id"
}
if m.recName == "" {
m.recName = "name"
}
// Add magic fields.
// Mirrors: odoo/orm/models.py BaseModel.MAGIC_COLUMNS
m.addMagicFields()
Registry.Register(m)
return m
}
// addMagicFields adds Odoo's automatic fields to every model.
// Mirrors: odoo/orm/models.py MAGIC_COLUMNS + LOG_ACCESS_COLUMNS
func (m *Model) addMagicFields() {
// id is always present
m.AddField(Integer("id", FieldOpts{
String: "ID",
Readonly: true,
}))
// display_name: computed on-the-fly from rec_name, not stored in DB.
// Mirrors: odoo/orm/models.py BaseModel.display_name (computed field on ALL models)
m.AddField(Char("display_name", FieldOpts{
String: "Display Name",
Compute: "_compute_display_name",
}))
if m.logAccess {
m.AddField(Many2one("create_uid", "res.users", FieldOpts{
String: "Created by",
Readonly: true,
}))
m.AddField(Datetime("create_date", FieldOpts{
String: "Created on",
Readonly: true,
}))
m.AddField(Many2one("write_uid", "res.users", FieldOpts{
String: "Last Updated by",
Readonly: true,
}))
m.AddField(Datetime("write_date", FieldOpts{
String: "Last Updated on",
Readonly: true,
}))
}
}
// AddField adds a field to this model.
func (m *Model) AddField(f *Field) *Model {
f.model = m
m.fields[f.Name] = f
m.allFields[f.Name] = f
m.fieldOrder = append(m.fieldOrder, f.Name)
return m
}
// AddFields adds multiple fields at once.
func (m *Model) AddFields(fields ...*Field) *Model {
for _, f := range fields {
m.AddField(f)
}
return m
}
// GetField returns a field by name, or nil.
func (m *Model) GetField(name string) *Field {
return m.allFields[name]
}
// Fields returns all fields of this model (including inherited).
func (m *Model) Fields() map[string]*Field {
return m.allFields
}
// StoredFields returns fields that have a database column.
func (m *Model) StoredFields() []*Field {
var result []*Field
for _, name := range m.fieldOrder {
f := m.fields[name]
if f.IsStored() {
result = append(result, f)
}
}
return result
}
// Name returns the model name (e.g., "res.partner").
func (m *Model) Name() string { return m.name }
// Table returns the SQL table name (e.g., "res_partner").
func (m *Model) Table() string { return m.table }
// Description returns the human-readable model description.
func (m *Model) Description() string { return m.description }
// Order returns the default sort expression.
func (m *Model) Order() string { return m.order }
// RecName returns the field name used as display name.
func (m *Model) RecName() string { return m.recName }
// IsAbstract returns true if this model has no database table.
func (m *Model) IsAbstract() bool { return m.modelType == ModelAbstract }
// IsTransient returns true if this is a wizard/temporary model.
func (m *Model) IsTransient() bool { return m.modelType == ModelTransient }
// SQLConstraint represents a SQL-level constraint on a model's table.
// Mirrors: _sql_constraints in Odoo.
type SQLConstraint struct {
Name string // Constraint name
Definition string // SQL definition (e.g., "UNIQUE(name, company_id)")
Message string // Error message
}
// AddSQLConstraint registers a SQL-level constraint on this model.
func (m *Model) AddSQLConstraint(name, definition, message string) {
m.SQLConstraints = append(m.SQLConstraints, SQLConstraint{Name: name, Definition: definition, Message: message})
}
// ConstraintFunc validates a recordset. Returns error if constraint violated.
// Mirrors: @api.constrains in Odoo.
type ConstraintFunc func(rs *Recordset) error
// MethodFunc is a callable business method on a model.
// Mirrors: Odoo model methods called via RPC.
type MethodFunc func(rs *Recordset, args ...interface{}) (interface{}, error)
// AddConstraint registers a validation constraint.
func (m *Model) AddConstraint(fn ConstraintFunc) *Model {
m.Constraints = append(m.Constraints, fn)
return m
}
// RegisterMethod registers a named business logic method on this model.
// Mirrors: Odoo's method definitions on model classes.
func (m *Model) RegisterMethod(name string, fn MethodFunc) *Model {
if m.Methods == nil {
m.Methods = make(map[string]MethodFunc)
}
m.Methods[name] = fn
return m
}
// RegisterOnchange registers an onchange handler for a field.
// When the field changes on the client, the handler is called with the current
// record values and returns computed field updates.
// Mirrors: @api.onchange('field_name') in Odoo.
func (m *Model) RegisterOnchange(fieldName string, handler func(env *Environment, vals Values) Values) {
if m.OnchangeHandlers == nil {
m.OnchangeHandlers = make(map[string]func(env *Environment, vals Values) Values)
}
m.OnchangeHandlers[fieldName] = handler
}
// Extend extends this model with additional fields (like _inherit in Odoo).
// Mirrors: class MyModelExt(models.Model): _inherit = 'res.partner'
func (m *Model) Extend(fields ...*Field) *Model {
for _, f := range fields {
m.AddField(f)
}
return m
}
// ExtendModel retrieves an existing model for extension by another module.
// Mirrors: Python Odoo's _inherit = 'model.name' (without _name).
//
// This is the formal mechanism for cross-module model extension. It provides:
// 1. Explicit intent (extending vs creating)
// 2. Panic on missing model (catch registration order bugs early)
// 3. Python Odoo compatibility pattern
//
// Usage:
//
// m := orm.ExtendModel("res.partner")
// m.AddFields(orm.Char("industry", orm.FieldOpts{String: "Industry"}))
// m.RegisterMethod("action_custom", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { ... })
func ExtendModel(name string) *Model {
m := Registry.Get(name)
if m == nil {
panic(fmt.Sprintf("orm: cannot extend unregistered model %q", name))
}
return m
}
// RegisterInverse registers an inverse method for a computed field.
// The method is auto-named "_inverse_<fieldName>" and linked to the field's
// Inverse property so that Write() calls it automatically.
// Mirrors: odoo/orm/fields.py Field.inverse
func (m *Model) RegisterInverse(fieldName string, fn MethodFunc) {
inverseName := "_inverse_" + fieldName
m.RegisterMethod(inverseName, fn)
if f := m.GetField(fieldName); f != nil {
f.Inverse = inverseName
}
}
// CreateTableSQL generates the CREATE TABLE statement for this model.
// Mirrors: odoo/orm/models.py BaseModel._table_exist / init()
func (m *Model) CreateTableSQL() string {
if !m.auto {
return ""
}
var cols []string
cols = append(cols, `"id" SERIAL PRIMARY KEY`)
for _, name := range m.fieldOrder {
f := m.fields[name]
if name == "id" || !f.IsStored() {
continue
}
sqlType := f.SQLType()
if sqlType == "" {
continue
}
col := fmt.Sprintf(" %q %s", f.Column(), sqlType)
if f.Required {
col += " NOT NULL"
}
cols = append(cols, col)
}
return fmt.Sprintf("CREATE TABLE IF NOT EXISTS %q (\n%s\n)",
m.table,
strings.Join(cols, ",\n"),
)
}
// ForeignKeySQL generates ALTER TABLE statements for foreign keys.
func (m *Model) ForeignKeySQL() []string {
var stmts []string
for _, name := range m.fieldOrder {
f := m.fields[name]
if f.Type != TypeMany2one || f.Comodel == "" {
continue
}
if name == "create_uid" || name == "write_uid" {
continue // Skip magic fields, added later
}
comodel := Registry.Get(f.Comodel)
if comodel == nil {
continue
}
onDelete := "SET NULL"
switch f.OnDelete {
case OnDeleteRestrict:
onDelete = "RESTRICT"
case OnDeleteCascade:
onDelete = "CASCADE"
}
stmt := fmt.Sprintf(
`ALTER TABLE %q ADD CONSTRAINT %q FOREIGN KEY (%q) REFERENCES %q ("id") ON DELETE %s`,
m.table,
fmt.Sprintf("%s_%s_fkey", m.table, f.Column()),
f.Column(),
comodel.Table(),
onDelete,
)
stmts = append(stmts, stmt)
}
return stmts
}
// IndexSQL generates CREATE INDEX statements.
func (m *Model) IndexSQL() []string {
var stmts []string
for _, name := range m.fieldOrder {
f := m.fields[name]
if !f.Index || !f.IsStored() || name == "id" {
continue
}
stmt := fmt.Sprintf(
`CREATE INDEX IF NOT EXISTS %q ON %q (%q)`,
fmt.Sprintf("%s_%s_index", m.table, f.Column()),
m.table,
f.Column(),
)
stmts = append(stmts, stmt)
}
return stmts
}
// Many2manyTableSQL generates junction table DDL for Many2many fields.
func (m *Model) Many2manyTableSQL() []string {
var stmts []string
for _, name := range m.fieldOrder {
f := m.fields[name]
if f.Type != TypeMany2many {
continue
}
comodel := Registry.Get(f.Comodel)
if comodel == nil {
continue
}
// Determine junction table name.
// Mirrors: odoo/orm/fields_relational.py Many2many._table_name
relation := f.Relation
if relation == "" {
// Default: alphabetically sorted model tables joined by underscore
t1, t2 := m.table, comodel.Table()
if t1 > t2 {
t1, t2 = t2, t1
}
relation = fmt.Sprintf("%s_%s_rel", t1, t2)
}
col1 := f.Column1
if col1 == "" {
col1 = m.table + "_id"
}
col2 := f.Column2
if col2 == "" {
col2 = comodel.Table() + "_id"
}
// Self-referential M2M: ensure distinct column names
if col1 == col2 {
col1 = m.table + "_src_id"
col2 = m.table + "_dst_id"
}
stmt := fmt.Sprintf(
"CREATE TABLE IF NOT EXISTS %q (\n"+
" %q integer NOT NULL REFERENCES %q(id) ON DELETE CASCADE,\n"+
" %q integer NOT NULL REFERENCES %q(id) ON DELETE CASCADE,\n"+
" PRIMARY KEY (%q, %q)\n"+
")",
relation, col1, m.table, col2, comodel.Table(), col1, col2,
)
stmts = append(stmts, stmt)
}
return stmts
}