Files
goodie/pkg/orm/field.go
Marc eb92a2e239 Complete ORM core: _inherits, Related write-back, Copy, constraints
Batch 1 (quick-fixes):
- Field.Copy attribute + IsCopyable() method for copy control
- Constraints now run in Write() (was only Create — bug fix)
- Readonly fields silently skipped in Write()
- active_test: auto-filter archived records in Search()

Batch 2 (Related field write-back):
- preprocessRelatedWrites() follows FK chain and writes to target model
- Enables Settings page to edit company name/address/etc.
- Loop protection via _write_related_depth context counter

Batch 3 (_inherits delegation):
- SetupAllInherits() generates Related fields from parent models
- preprocessInheritsCreate() auto-creates parent records on Create
- Declared on res.users, res.company, product.product
- Called in LoadModules before compute setup

Batch 4 (Copy method):
- Recordset.Copy(defaults) with blacklist, IsCopyable check
- M2M re-linking, rec_name "(copy)" suffix
- Replaces simplified copy case in server dispatch

Batch 5 (Onchange compute):
- RunOnchangeComputes() triggers dependent computes on field change
- Virtual record (ID=-1) with client values in cache
- Integrated into onchange RPC handler

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

404 lines
11 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
// Copy
Copy bool // Whether this field is copied on record duplication
// 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()
}
// IsCopyable returns true if this field should be copied on record duplication.
// Mirrors: odoo/orm/fields.py Field.copy
func (f *Field) IsCopyable() bool {
// If explicitly set, use that
if f.Copy {
return true
}
// Defaults: non-copyable for computed, O2M, id, timestamps
if f.Name == "id" || f.Name == "create_uid" || f.Name == "create_date" ||
f.Name == "write_uid" || f.Name == "write_date" || f.Name == "password" {
return false
}
if f.Compute != "" && !f.Store {
return false
}
if f.Type == TypeOne2many {
return false
}
return true
}
// 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
Copy 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,
Copy: opts.Copy,
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
}