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>
452 lines
12 KiB
Go
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
|
|
}
|