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:
68
pkg/orm/command.go
Normal file
68
pkg/orm/command.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package orm
|
||||
|
||||
// Command represents an ORM write command for One2many/Many2many fields.
|
||||
// Mirrors: odoo/orm/fields.py Command class
|
||||
//
|
||||
// Odoo uses a tuple-based command system:
|
||||
//
|
||||
// (0, 0, {values}) → CREATE: create new record
|
||||
// (1, id, {values}) → UPDATE: update existing record
|
||||
// (2, id, 0) → DELETE: delete record
|
||||
// (3, id, 0) → UNLINK: remove link (M2M only)
|
||||
// (4, id, 0) → LINK: add link (M2M only)
|
||||
// (5, 0, 0) → CLEAR: remove all links (M2M only)
|
||||
// (6, 0, [ids]) → SET: replace all links (M2M only)
|
||||
type Command struct {
|
||||
Operation CommandOp
|
||||
ID int64
|
||||
Values Values
|
||||
IDs []int64 // For SET command
|
||||
}
|
||||
|
||||
// CommandOp is the command operation type.
|
||||
type CommandOp int
|
||||
|
||||
const (
|
||||
CommandCreate CommandOp = 0
|
||||
CommandUpdate CommandOp = 1
|
||||
CommandDelete CommandOp = 2
|
||||
CommandUnlink CommandOp = 3
|
||||
CommandLink CommandOp = 4
|
||||
CommandClear CommandOp = 5
|
||||
CommandSet CommandOp = 6
|
||||
)
|
||||
|
||||
// CmdCreate returns a CREATE command. Mirrors: Command.create(values)
|
||||
func CmdCreate(values Values) Command {
|
||||
return Command{Operation: CommandCreate, Values: values}
|
||||
}
|
||||
|
||||
// CmdUpdate returns an UPDATE command. Mirrors: Command.update(id, values)
|
||||
func CmdUpdate(id int64, values Values) Command {
|
||||
return Command{Operation: CommandUpdate, ID: id, Values: values}
|
||||
}
|
||||
|
||||
// CmdDelete returns a DELETE command. Mirrors: Command.delete(id)
|
||||
func CmdDelete(id int64) Command {
|
||||
return Command{Operation: CommandDelete, ID: id}
|
||||
}
|
||||
|
||||
// CmdUnlink returns an UNLINK command. Mirrors: Command.unlink(id)
|
||||
func CmdUnlink(id int64) Command {
|
||||
return Command{Operation: CommandUnlink, ID: id}
|
||||
}
|
||||
|
||||
// CmdLink returns a LINK command. Mirrors: Command.link(id)
|
||||
func CmdLink(id int64) Command {
|
||||
return Command{Operation: CommandLink, ID: id}
|
||||
}
|
||||
|
||||
// CmdClear returns a CLEAR command. Mirrors: Command.clear()
|
||||
func CmdClear() Command {
|
||||
return Command{Operation: CommandClear}
|
||||
}
|
||||
|
||||
// CmdSet returns a SET command. Mirrors: Command.set(ids)
|
||||
func CmdSet(ids []int64) Command {
|
||||
return Command{Operation: CommandSet, IDs: ids}
|
||||
}
|
||||
204
pkg/orm/compute.go
Normal file
204
pkg/orm/compute.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package orm
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ComputeFunc is a function that computes field values for a recordset.
|
||||
// Mirrors: @api.depends decorated methods in Odoo.
|
||||
//
|
||||
// The function receives a singleton recordset and must return a Values map
|
||||
// with the computed field values.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// func computeAmount(rs *Recordset) (Values, error) {
|
||||
// total := 0.0
|
||||
// // ... sum line amounts ...
|
||||
// return Values{"amount_total": total}, nil
|
||||
// }
|
||||
type ComputeFunc func(rs *Recordset) (Values, error)
|
||||
|
||||
// RegisterCompute registers a compute function for a field.
|
||||
// The same function can compute multiple fields (call RegisterCompute for each).
|
||||
func (m *Model) RegisterCompute(fieldName string, fn ComputeFunc) {
|
||||
if m.computes == nil {
|
||||
m.computes = make(map[string]ComputeFunc)
|
||||
}
|
||||
m.computes[fieldName] = fn
|
||||
}
|
||||
|
||||
// SetupComputes builds the reverse dependency map for this model.
|
||||
// Called after all modules are loaded.
|
||||
//
|
||||
// For each field with Depends, creates a mapping:
|
||||
//
|
||||
// trigger_field → []computed_field_names
|
||||
//
|
||||
// So when trigger_field is written, we know which computes to re-run.
|
||||
func (m *Model) SetupComputes() {
|
||||
m.dependencyMap = make(map[string][]string)
|
||||
for _, name := range m.fieldOrder {
|
||||
f := m.fields[name]
|
||||
if f.Compute == "" || len(f.Depends) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, dep := range f.Depends {
|
||||
m.dependencyMap[dep] = append(m.dependencyMap[dep], name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RunStoredComputes runs compute functions for stored computed fields
|
||||
// and merges results into vals. Called before INSERT in Create().
|
||||
func RunStoredComputes(m *Model, env *Environment, id int64, vals Values) error {
|
||||
if len(m.computes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect stored computed fields that have registered functions
|
||||
seen := make(map[string]bool) // Track compute functions already called (by field name)
|
||||
|
||||
for _, name := range m.fieldOrder {
|
||||
f := m.fields[name]
|
||||
if f.Compute == "" || !f.Store {
|
||||
continue
|
||||
}
|
||||
fn, ok := m.computes[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Deduplicate: same function may compute multiple fields
|
||||
if seen[f.Compute] {
|
||||
continue
|
||||
}
|
||||
seen[f.Compute] = true
|
||||
|
||||
// Create a temporary recordset for the computation
|
||||
rs := &Recordset{env: env, model: m, ids: []int64{id}}
|
||||
computed, err := fn(rs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("orm: compute %s.%s: %w", m.name, name, err)
|
||||
}
|
||||
|
||||
// Merge computed values
|
||||
for k, v := range computed {
|
||||
cf := m.GetField(k)
|
||||
if cf != nil && cf.Store && cf.Compute != "" {
|
||||
vals[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TriggerRecompute re-computes stored fields that depend on written fields.
|
||||
// Called after UPDATE in Write().
|
||||
func TriggerRecompute(rs *Recordset, writtenFields Values) error {
|
||||
m := rs.model
|
||||
if len(m.dependencyMap) == 0 || len(m.computes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find which computed fields need re-computation
|
||||
toRecompute := make(map[string]bool)
|
||||
for fieldName := range writtenFields {
|
||||
for _, computedField := range m.dependencyMap[fieldName] {
|
||||
toRecompute[computedField] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(toRecompute) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run computes for each record
|
||||
seen := make(map[string]bool)
|
||||
for _, id := range rs.IDs() {
|
||||
singleton := rs.Browse(id)
|
||||
recomputedVals := make(Values)
|
||||
|
||||
for fieldName := range toRecompute {
|
||||
f := m.GetField(fieldName)
|
||||
if f == nil || !f.Store {
|
||||
continue
|
||||
}
|
||||
fn, ok := m.computes[fieldName]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if seen[f.Compute] {
|
||||
continue
|
||||
}
|
||||
seen[f.Compute] = true
|
||||
|
||||
computed, err := fn(singleton)
|
||||
if err != nil {
|
||||
return fmt.Errorf("orm: recompute %s.%s: %w", m.name, fieldName, err)
|
||||
}
|
||||
for k, v := range computed {
|
||||
cf := m.GetField(k)
|
||||
if cf != nil && cf.Store && cf.Compute != "" {
|
||||
recomputedVals[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write recomputed values directly to DB (bypass hooks to avoid infinite loop)
|
||||
if len(recomputedVals) > 0 {
|
||||
if err := writeDirectNohook(rs.env, m, id, recomputedVals); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Reset seen for next record
|
||||
seen = make(map[string]bool)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeDirectNohook writes values directly without triggering hooks or recomputes.
|
||||
func writeDirectNohook(env *Environment, m *Model, id int64, vals Values) error {
|
||||
var setClauses []string
|
||||
var args []interface{}
|
||||
idx := 1
|
||||
|
||||
for k, v := range vals {
|
||||
f := m.GetField(k)
|
||||
if f == nil || !f.IsStored() {
|
||||
continue
|
||||
}
|
||||
setClauses = append(setClauses, fmt.Sprintf("%q = $%d", f.Column(), idx))
|
||||
args = append(args, v)
|
||||
idx++
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
args = append(args, id)
|
||||
query := fmt.Sprintf(
|
||||
`UPDATE %q SET %s WHERE "id" = $%d`,
|
||||
m.Table(), joinStrings(setClauses, ", "), idx,
|
||||
)
|
||||
|
||||
_, err := env.tx.Exec(env.ctx, query, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func joinStrings(s []string, sep string) string {
|
||||
result := ""
|
||||
for i, str := range s {
|
||||
if i > 0 {
|
||||
result += sep
|
||||
}
|
||||
result += str
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// SetupAllComputes calls SetupComputes on all registered models.
|
||||
func SetupAllComputes() {
|
||||
for _, m := range Registry.Models() {
|
||||
m.SetupComputes()
|
||||
}
|
||||
}
|
||||
429
pkg/orm/domain.go
Normal file
429
pkg/orm/domain.go
Normal file
@@ -0,0 +1,429 @@
|
||||
package orm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Domain represents a search filter expression.
|
||||
// Mirrors: odoo/orm/domains.py Domain class
|
||||
//
|
||||
// Odoo uses prefix (Polish) notation:
|
||||
//
|
||||
// ['&', ('name', 'ilike', 'test'), ('active', '=', True)]
|
||||
//
|
||||
// Go equivalent:
|
||||
//
|
||||
// And(Leaf("name", "ilike", "test"), Leaf("active", "=", true))
|
||||
type Domain []DomainNode
|
||||
|
||||
// DomainNode is either an Operator or a Condition (leaf).
|
||||
type DomainNode interface {
|
||||
isDomainNode()
|
||||
}
|
||||
|
||||
// Operator is a logical operator in a domain expression.
|
||||
// Mirrors: odoo/orm/domains.py DOMAIN_OPERATORS
|
||||
type Operator string
|
||||
|
||||
const (
|
||||
OpAnd Operator = "&"
|
||||
OpOr Operator = "|"
|
||||
OpNot Operator = "!"
|
||||
)
|
||||
|
||||
func (o Operator) isDomainNode() {}
|
||||
|
||||
// Condition is a leaf node in a domain expression.
|
||||
// Mirrors: odoo/orm/domains.py DomainLeaf
|
||||
//
|
||||
// Odoo: ('field_name', 'operator', value)
|
||||
type Condition struct {
|
||||
Field string // Field name (supports dot notation: "partner_id.name")
|
||||
Operator string // Comparison operator
|
||||
Value Value // Comparison value
|
||||
}
|
||||
|
||||
func (c Condition) isDomainNode() {}
|
||||
|
||||
// Valid comparison operators.
|
||||
// Mirrors: odoo/orm/domains.py COMPARISON_OPERATORS
|
||||
var validOperators = map[string]bool{
|
||||
"=": true, "!=": true,
|
||||
"<": true, ">": true, "<=": true, ">=": true,
|
||||
"in": true, "not in": true,
|
||||
"like": true, "not like": true,
|
||||
"ilike": true, "not ilike": true,
|
||||
"=like": true, "=ilike": true,
|
||||
"any": true, "not any": true,
|
||||
"child_of": true, "parent_of": true,
|
||||
}
|
||||
|
||||
// Leaf creates a domain condition (leaf node).
|
||||
func Leaf(field, operator string, value Value) Condition {
|
||||
return Condition{Field: field, Operator: operator, Value: value}
|
||||
}
|
||||
|
||||
// And combines conditions with AND (default in Odoo).
|
||||
func And(nodes ...DomainNode) Domain {
|
||||
if len(nodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(nodes) == 1 {
|
||||
return Domain{nodes[0]}
|
||||
}
|
||||
result := Domain{}
|
||||
for i := 0; i < len(nodes)-1; i++ {
|
||||
result = append(result, OpAnd)
|
||||
}
|
||||
result = append(result, nodes...)
|
||||
return result
|
||||
}
|
||||
|
||||
// Or combines conditions with OR.
|
||||
func Or(nodes ...DomainNode) Domain {
|
||||
if len(nodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(nodes) == 1 {
|
||||
return Domain{nodes[0]}
|
||||
}
|
||||
result := Domain{}
|
||||
for i := 0; i < len(nodes)-1; i++ {
|
||||
result = append(result, OpOr)
|
||||
}
|
||||
result = append(result, nodes...)
|
||||
return result
|
||||
}
|
||||
|
||||
// Not negates a condition.
|
||||
func Not(node DomainNode) Domain {
|
||||
return Domain{OpNot, node}
|
||||
}
|
||||
|
||||
// DomainCompiler compiles a Domain to SQL WHERE clause.
|
||||
// Mirrors: odoo/orm/domains.py Domain._to_sql()
|
||||
type DomainCompiler struct {
|
||||
model *Model
|
||||
params []interface{}
|
||||
joins []joinClause
|
||||
aliasCounter int
|
||||
}
|
||||
|
||||
type joinClause struct {
|
||||
table string
|
||||
alias string
|
||||
on string
|
||||
}
|
||||
|
||||
// CompileResult holds the compiled SQL WHERE clause, JOINs, and parameters.
|
||||
type CompileResult struct {
|
||||
Where string
|
||||
Joins string
|
||||
Params []interface{}
|
||||
}
|
||||
|
||||
// Compile converts a domain to a SQL WHERE clause with parameters and JOINs.
|
||||
func (dc *DomainCompiler) Compile(domain Domain) (string, []interface{}, error) {
|
||||
if len(domain) == 0 {
|
||||
return "TRUE", nil, nil
|
||||
}
|
||||
|
||||
dc.params = nil
|
||||
dc.joins = nil
|
||||
dc.aliasCounter = 0
|
||||
sql, err := dc.compileNodes(domain, 0)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return sql, dc.params, nil
|
||||
}
|
||||
|
||||
// JoinSQL returns the SQL JOIN clauses generated during compilation.
|
||||
func (dc *DomainCompiler) JoinSQL() string {
|
||||
if len(dc.joins) == 0 {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
for _, j := range dc.joins {
|
||||
parts = append(parts, fmt.Sprintf("LEFT JOIN %q AS %q ON %s", j.table, j.alias, j.on))
|
||||
}
|
||||
return " " + strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
|
||||
if pos >= len(domain) {
|
||||
return "TRUE", nil
|
||||
}
|
||||
|
||||
node := domain[pos]
|
||||
|
||||
switch n := node.(type) {
|
||||
case Operator:
|
||||
switch n {
|
||||
case OpAnd:
|
||||
left, err := dc.compileNodes(domain, pos+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
right, err := dc.compileNodes(domain, pos+2)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("(%s AND %s)", left, right), nil
|
||||
|
||||
case OpOr:
|
||||
left, err := dc.compileNodes(domain, pos+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
right, err := dc.compileNodes(domain, pos+2)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("(%s OR %s)", left, right), nil
|
||||
|
||||
case OpNot:
|
||||
inner, err := dc.compileNodes(domain, pos+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("(NOT %s)", inner), nil
|
||||
}
|
||||
|
||||
case Condition:
|
||||
return dc.compileCondition(n)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("unexpected domain node at position %d: %v", pos, node)
|
||||
}
|
||||
|
||||
func (dc *DomainCompiler) compileCondition(c Condition) (string, error) {
|
||||
if !validOperators[c.Operator] {
|
||||
return "", fmt.Errorf("invalid operator: %q", c.Operator)
|
||||
}
|
||||
|
||||
// Handle dot notation (e.g., "partner_id.name")
|
||||
parts := strings.Split(c.Field, ".")
|
||||
column := parts[0]
|
||||
|
||||
// TODO: Handle JOINs for dot notation paths
|
||||
// For now, only support direct fields
|
||||
if len(parts) > 1 {
|
||||
// Placeholder for JOIN resolution
|
||||
return dc.compileJoinedCondition(parts, c.Operator, c.Value)
|
||||
}
|
||||
|
||||
return dc.compileSimpleCondition(column, c.Operator, c.Value)
|
||||
}
|
||||
|
||||
func (dc *DomainCompiler) compileSimpleCondition(column, operator string, value Value) (string, error) {
|
||||
paramIdx := len(dc.params) + 1
|
||||
|
||||
switch operator {
|
||||
case "=", "!=", "<", ">", "<=", ">=":
|
||||
if value == nil || value == false {
|
||||
if operator == "=" {
|
||||
return fmt.Sprintf("%q IS NULL", column), nil
|
||||
}
|
||||
return fmt.Sprintf("%q IS NOT NULL", column), nil
|
||||
}
|
||||
dc.params = append(dc.params, value)
|
||||
return fmt.Sprintf("%q %s $%d", column, operator, paramIdx), nil
|
||||
|
||||
case "in":
|
||||
vals := normalizeSlice(value)
|
||||
if vals == nil {
|
||||
return "", fmt.Errorf("'in' operator requires a slice value")
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
return "FALSE", nil
|
||||
}
|
||||
placeholders := make([]string, len(vals))
|
||||
for i, v := range vals {
|
||||
dc.params = append(dc.params, v)
|
||||
placeholders[i] = fmt.Sprintf("$%d", paramIdx+i)
|
||||
}
|
||||
return fmt.Sprintf("%q IN (%s)", column, strings.Join(placeholders, ", ")), nil
|
||||
|
||||
case "not in":
|
||||
vals := normalizeSlice(value)
|
||||
if vals == nil {
|
||||
return "", fmt.Errorf("'not in' operator requires a slice value")
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
return "TRUE", nil
|
||||
}
|
||||
placeholders := make([]string, len(vals))
|
||||
for i, v := range vals {
|
||||
dc.params = append(dc.params, v)
|
||||
placeholders[i] = fmt.Sprintf("$%d", paramIdx+i)
|
||||
}
|
||||
return fmt.Sprintf("%q NOT IN (%s)", column, strings.Join(placeholders, ", ")), nil
|
||||
|
||||
case "like":
|
||||
dc.params = append(dc.params, value)
|
||||
return fmt.Sprintf("%q LIKE $%d", column, paramIdx), nil
|
||||
|
||||
case "not like":
|
||||
dc.params = append(dc.params, value)
|
||||
return fmt.Sprintf("%q NOT LIKE $%d", column, paramIdx), nil
|
||||
|
||||
case "ilike":
|
||||
dc.params = append(dc.params, value)
|
||||
return fmt.Sprintf("%q ILIKE $%d", column, paramIdx), nil
|
||||
|
||||
case "not ilike":
|
||||
dc.params = append(dc.params, value)
|
||||
return fmt.Sprintf("%q NOT ILIKE $%d", column, paramIdx), nil
|
||||
|
||||
case "=like":
|
||||
dc.params = append(dc.params, value)
|
||||
return fmt.Sprintf("%q LIKE $%d", column, paramIdx), nil
|
||||
|
||||
case "=ilike":
|
||||
dc.params = append(dc.params, value)
|
||||
return fmt.Sprintf("%q ILIKE $%d", column, paramIdx), nil
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("unhandled operator: %q", operator)
|
||||
}
|
||||
}
|
||||
|
||||
// compileJoinedCondition resolves dot-notation paths (e.g., "partner_id.country_id.code")
|
||||
// by generating LEFT JOINs through the relational chain.
|
||||
func (dc *DomainCompiler) compileJoinedCondition(fieldPath []string, operator string, value Value) (string, error) {
|
||||
currentModel := dc.model
|
||||
currentAlias := dc.model.Table()
|
||||
|
||||
// Walk the path: each segment except the last is a Many2one FK to JOIN through
|
||||
for i := 0; i < len(fieldPath)-1; i++ {
|
||||
fieldName := fieldPath[i]
|
||||
f := currentModel.GetField(fieldName)
|
||||
if f == nil {
|
||||
return "", fmt.Errorf("field %q not found on %s", fieldName, currentModel.Name())
|
||||
}
|
||||
if f.Type != TypeMany2one {
|
||||
return "", fmt.Errorf("field %q on %s is not Many2one, cannot traverse", fieldName, currentModel.Name())
|
||||
}
|
||||
|
||||
comodel := Registry.Get(f.Comodel)
|
||||
if comodel == nil {
|
||||
return "", fmt.Errorf("comodel %q not found for field %q", f.Comodel, fieldName)
|
||||
}
|
||||
|
||||
// Generate alias and JOIN
|
||||
dc.aliasCounter++
|
||||
alias := fmt.Sprintf("_j%d", dc.aliasCounter)
|
||||
dc.joins = append(dc.joins, joinClause{
|
||||
table: comodel.Table(),
|
||||
alias: alias,
|
||||
on: fmt.Sprintf("%s.%q = %q.\"id\"", currentAlias, f.Column(), alias),
|
||||
})
|
||||
|
||||
currentModel = comodel
|
||||
currentAlias = alias
|
||||
}
|
||||
|
||||
// The last segment is the actual field to filter on
|
||||
leafField := fieldPath[len(fieldPath)-1]
|
||||
qualifiedColumn := fmt.Sprintf("%s.%q", currentAlias, leafField)
|
||||
|
||||
return dc.compileQualifiedCondition(qualifiedColumn, operator, value)
|
||||
}
|
||||
|
||||
// compileQualifiedCondition compiles a condition with a fully qualified column (alias.column).
|
||||
func (dc *DomainCompiler) compileQualifiedCondition(qualifiedColumn, operator string, value Value) (string, error) {
|
||||
paramIdx := len(dc.params) + 1
|
||||
|
||||
switch operator {
|
||||
case "=", "!=", "<", ">", "<=", ">=":
|
||||
if value == nil || value == false {
|
||||
if operator == "=" {
|
||||
return fmt.Sprintf("%s IS NULL", qualifiedColumn), nil
|
||||
}
|
||||
return fmt.Sprintf("%s IS NOT NULL", qualifiedColumn), nil
|
||||
}
|
||||
dc.params = append(dc.params, value)
|
||||
return fmt.Sprintf("%s %s $%d", qualifiedColumn, operator, paramIdx), nil
|
||||
|
||||
case "in", "not in":
|
||||
vals := normalizeSlice(value)
|
||||
if vals == nil {
|
||||
return "FALSE", nil
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
if operator == "in" {
|
||||
return "FALSE", nil
|
||||
}
|
||||
return "TRUE", nil
|
||||
}
|
||||
placeholders := make([]string, len(vals))
|
||||
for i, v := range vals {
|
||||
dc.params = append(dc.params, v)
|
||||
placeholders[i] = fmt.Sprintf("$%d", paramIdx+i)
|
||||
}
|
||||
op := "IN"
|
||||
if operator == "not in" {
|
||||
op = "NOT IN"
|
||||
}
|
||||
return fmt.Sprintf("%s %s (%s)", qualifiedColumn, op, strings.Join(placeholders, ", ")), nil
|
||||
|
||||
case "like", "not like", "ilike", "not ilike", "=like", "=ilike":
|
||||
dc.params = append(dc.params, value)
|
||||
sqlOp := strings.ToUpper(strings.TrimPrefix(operator, "="))
|
||||
if strings.HasPrefix(operator, "=") {
|
||||
sqlOp = strings.ToUpper(operator[1:])
|
||||
}
|
||||
switch operator {
|
||||
case "like":
|
||||
sqlOp = "LIKE"
|
||||
case "not like":
|
||||
sqlOp = "NOT LIKE"
|
||||
case "ilike", "=ilike":
|
||||
sqlOp = "ILIKE"
|
||||
case "not ilike":
|
||||
sqlOp = "NOT ILIKE"
|
||||
case "=like":
|
||||
sqlOp = "LIKE"
|
||||
}
|
||||
return fmt.Sprintf("%s %s $%d", qualifiedColumn, sqlOp, paramIdx), nil
|
||||
|
||||
default:
|
||||
dc.params = append(dc.params, value)
|
||||
return fmt.Sprintf("%s %s $%d", qualifiedColumn, operator, paramIdx), nil
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeSlice converts typed slices to []interface{} for IN/NOT IN operators.
|
||||
func normalizeSlice(value Value) []interface{} {
|
||||
switch v := value.(type) {
|
||||
case []interface{}:
|
||||
return v
|
||||
case []int64:
|
||||
out := make([]interface{}, len(v))
|
||||
for i, x := range v {
|
||||
out[i] = x
|
||||
}
|
||||
return out
|
||||
case []float64:
|
||||
out := make([]interface{}, len(v))
|
||||
for i, x := range v {
|
||||
out[i] = x
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
out := make([]interface{}, len(v))
|
||||
for i, x := range v {
|
||||
out[i] = x
|
||||
}
|
||||
return out
|
||||
case []int:
|
||||
out := make([]interface{}, len(v))
|
||||
for i, x := range v {
|
||||
out[i] = x
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
257
pkg/orm/environment.go
Normal file
257
pkg/orm/environment.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package orm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Environment is the central context for all ORM operations.
|
||||
// Mirrors: odoo/orm/environments.py Environment
|
||||
//
|
||||
// Odoo: self.env['res.partner'].search([('name', 'ilike', 'test')])
|
||||
// Go: env.Model("res.partner").Search(And(Leaf("name", "ilike", "test")))
|
||||
//
|
||||
// An Environment wraps:
|
||||
// - A database transaction (cursor in Odoo terms)
|
||||
// - The current user ID
|
||||
// - The current company ID
|
||||
// - Context values (lang, tz, etc.)
|
||||
type Environment struct {
|
||||
ctx context.Context
|
||||
pool *pgxpool.Pool
|
||||
tx pgx.Tx
|
||||
uid int64
|
||||
companyID int64
|
||||
su bool // sudo mode (bypass access checks)
|
||||
context map[string]interface{}
|
||||
cache *Cache
|
||||
}
|
||||
|
||||
// EnvConfig configures a new Environment.
|
||||
type EnvConfig struct {
|
||||
Pool *pgxpool.Pool
|
||||
UID int64
|
||||
CompanyID int64
|
||||
Context map[string]interface{}
|
||||
}
|
||||
|
||||
// NewEnvironment creates a new ORM environment.
|
||||
// Mirrors: odoo.api.Environment(cr, uid, context)
|
||||
func NewEnvironment(ctx context.Context, cfg EnvConfig) (*Environment, error) {
|
||||
tx, err := cfg.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("orm: begin transaction: %w", err)
|
||||
}
|
||||
|
||||
envCtx := cfg.Context
|
||||
if envCtx == nil {
|
||||
envCtx = make(map[string]interface{})
|
||||
}
|
||||
|
||||
return &Environment{
|
||||
ctx: ctx,
|
||||
pool: cfg.Pool,
|
||||
tx: tx,
|
||||
uid: cfg.UID,
|
||||
companyID: cfg.CompanyID,
|
||||
context: envCtx,
|
||||
cache: NewCache(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Model returns a Recordset bound to this environment for the given model.
|
||||
// Mirrors: self.env['model.name']
|
||||
func (env *Environment) Model(name string) *Recordset {
|
||||
m := Registry.MustGet(name)
|
||||
return &Recordset{
|
||||
env: env,
|
||||
model: m,
|
||||
ids: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// Ref returns a record by its XML ID (external identifier).
|
||||
// Mirrors: self.env.ref('module.xml_id')
|
||||
func (env *Environment) Ref(xmlID string) (*Recordset, error) {
|
||||
// Query ir_model_data for the external ID
|
||||
var resModel string
|
||||
var resID int64
|
||||
|
||||
err := env.tx.QueryRow(env.ctx,
|
||||
`SELECT model, res_id FROM ir_model_data WHERE module || '.' || name = $1 LIMIT 1`,
|
||||
xmlID,
|
||||
).Scan(&resModel, &resID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("orm: ref %q not found: %w", xmlID, err)
|
||||
}
|
||||
|
||||
return env.Model(resModel).Browse(resID), nil
|
||||
}
|
||||
|
||||
// UID returns the current user ID.
|
||||
func (env *Environment) UID() int64 { return env.uid }
|
||||
|
||||
// CompanyID returns the current company ID.
|
||||
func (env *Environment) CompanyID() int64 { return env.companyID }
|
||||
|
||||
// IsSuperuser returns true if this environment bypasses access checks.
|
||||
func (env *Environment) IsSuperuser() bool { return env.su }
|
||||
|
||||
// Context returns the environment context (Odoo-style key-value context).
|
||||
func (env *Environment) Context() map[string]interface{} { return env.context }
|
||||
|
||||
// Ctx returns the Go context.Context for database operations.
|
||||
func (env *Environment) Ctx() context.Context { return env.ctx }
|
||||
|
||||
// Lang returns the language from context.
|
||||
func (env *Environment) Lang() string {
|
||||
if lang, ok := env.context["lang"].(string); ok {
|
||||
return lang
|
||||
}
|
||||
return "en_US"
|
||||
}
|
||||
|
||||
// Tx returns the underlying database transaction.
|
||||
func (env *Environment) Tx() pgx.Tx { return env.tx }
|
||||
|
||||
// Sudo returns a new environment with superuser privileges.
|
||||
// Mirrors: self.env['model'].sudo()
|
||||
func (env *Environment) Sudo() *Environment {
|
||||
return &Environment{
|
||||
ctx: env.ctx,
|
||||
pool: env.pool,
|
||||
tx: env.tx,
|
||||
uid: env.uid,
|
||||
companyID: env.companyID,
|
||||
su: true,
|
||||
context: env.context,
|
||||
cache: env.cache,
|
||||
}
|
||||
}
|
||||
|
||||
// WithUser returns a new environment for a different user.
|
||||
// Mirrors: self.with_user(user_id)
|
||||
func (env *Environment) WithUser(uid int64) *Environment {
|
||||
return &Environment{
|
||||
ctx: env.ctx,
|
||||
pool: env.pool,
|
||||
tx: env.tx,
|
||||
uid: uid,
|
||||
companyID: env.companyID,
|
||||
su: false,
|
||||
context: env.context,
|
||||
cache: env.cache,
|
||||
}
|
||||
}
|
||||
|
||||
// WithCompany returns a new environment for a different company.
|
||||
// Mirrors: self.with_company(company_id)
|
||||
func (env *Environment) WithCompany(companyID int64) *Environment {
|
||||
return &Environment{
|
||||
ctx: env.ctx,
|
||||
pool: env.pool,
|
||||
tx: env.tx,
|
||||
uid: env.uid,
|
||||
companyID: companyID,
|
||||
su: env.su,
|
||||
context: env.context,
|
||||
cache: env.cache,
|
||||
}
|
||||
}
|
||||
|
||||
// WithContext returns a new environment with additional context values.
|
||||
// Mirrors: self.with_context(key=value)
|
||||
func (env *Environment) WithContext(extra map[string]interface{}) *Environment {
|
||||
merged := make(map[string]interface{}, len(env.context)+len(extra))
|
||||
for k, v := range env.context {
|
||||
merged[k] = v
|
||||
}
|
||||
for k, v := range extra {
|
||||
merged[k] = v
|
||||
}
|
||||
return &Environment{
|
||||
ctx: env.ctx,
|
||||
pool: env.pool,
|
||||
tx: env.tx,
|
||||
uid: env.uid,
|
||||
companyID: env.companyID,
|
||||
su: env.su,
|
||||
context: merged,
|
||||
cache: env.cache,
|
||||
}
|
||||
}
|
||||
|
||||
// Commit commits the database transaction.
|
||||
func (env *Environment) Commit() error {
|
||||
return env.tx.Commit(env.ctx)
|
||||
}
|
||||
|
||||
// Rollback rolls back the database transaction.
|
||||
func (env *Environment) Rollback() error {
|
||||
return env.tx.Rollback(env.ctx)
|
||||
}
|
||||
|
||||
// Close rolls back uncommitted changes and releases the connection.
|
||||
func (env *Environment) Close() error {
|
||||
return env.tx.Rollback(env.ctx)
|
||||
}
|
||||
|
||||
// --- Cache ---
|
||||
|
||||
// Cache is a per-environment record cache.
|
||||
// Mirrors: odoo/orm/environments.py Transaction cache
|
||||
type Cache struct {
|
||||
// model_name → record_id → field_name → value
|
||||
data map[string]map[int64]map[string]Value
|
||||
}
|
||||
|
||||
// NewCache creates an empty cache.
|
||||
func NewCache() *Cache {
|
||||
return &Cache{
|
||||
data: make(map[string]map[int64]map[string]Value),
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves a cached value.
|
||||
func (c *Cache) Get(model string, id int64, field string) (Value, bool) {
|
||||
records, ok := c.data[model]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
fields, ok := records[id]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
val, ok := fields[field]
|
||||
return val, ok
|
||||
}
|
||||
|
||||
// Set stores a value in the cache.
|
||||
func (c *Cache) Set(model string, id int64, field string, value Value) {
|
||||
records, ok := c.data[model]
|
||||
if !ok {
|
||||
records = make(map[int64]map[string]Value)
|
||||
c.data[model] = records
|
||||
}
|
||||
fields, ok := records[id]
|
||||
if !ok {
|
||||
fields = make(map[string]Value)
|
||||
records[id] = fields
|
||||
}
|
||||
fields[field] = value
|
||||
}
|
||||
|
||||
// Invalidate removes all cached values for a model.
|
||||
func (c *Cache) Invalidate(model string) {
|
||||
delete(c.data, model)
|
||||
}
|
||||
|
||||
// InvalidateRecord removes cached values for a specific record.
|
||||
func (c *Cache) InvalidateRecord(model string, id int64) {
|
||||
if records, ok := c.data[model]; ok {
|
||||
delete(records, id)
|
||||
}
|
||||
}
|
||||
377
pkg/orm/field.go
Normal file
377
pkg/orm/field.go
Normal file
@@ -0,0 +1,377 @@
|
||||
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
|
||||
}
|
||||
378
pkg/orm/model.go
Normal file
378
pkg/orm/model.go
Normal file
@@ -0,0 +1,378 @@
|
||||
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
|
||||
Constraints []ConstraintFunc // Validation constraints
|
||||
Methods map[string]MethodFunc // Named business methods
|
||||
|
||||
// Computed fields
|
||||
computes map[string]ComputeFunc // field_name → compute function
|
||||
dependencyMap map[string][]string // trigger_field → []computed_field_names
|
||||
|
||||
// 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,
|
||||
}))
|
||||
|
||||
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 }
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
796
pkg/orm/recordset.go
Normal file
796
pkg/orm/recordset.go
Normal 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
|
||||
}
|
||||
282
pkg/orm/relational.go
Normal file
282
pkg/orm/relational.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package orm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProcessO2MCommands processes One2many field commands after a Create/Write.
|
||||
// Mirrors: odoo/orm/fields_relational.py One2many write logic
|
||||
//
|
||||
// Commands:
|
||||
//
|
||||
// CmdCreate(vals) → Create child record with inverse_field = parentID
|
||||
// CmdUpdate(id, vals) → Update child record
|
||||
// CmdDelete(id) → Delete child record
|
||||
func ProcessO2MCommands(env *Environment, f *Field, parentID int64, cmds []Command) error {
|
||||
if f.Comodel == "" || f.InverseField == "" {
|
||||
return fmt.Errorf("orm: O2M field %q missing comodel or inverse_field", f.Name)
|
||||
}
|
||||
|
||||
comodelRS := env.Model(f.Comodel)
|
||||
|
||||
for _, cmd := range cmds {
|
||||
switch cmd.Operation {
|
||||
case CommandCreate:
|
||||
vals := cmd.Values
|
||||
if vals == nil {
|
||||
vals = make(Values)
|
||||
}
|
||||
vals[f.InverseField] = parentID
|
||||
if _, err := comodelRS.Create(vals); err != nil {
|
||||
return fmt.Errorf("orm: O2M create on %s: %w", f.Comodel, err)
|
||||
}
|
||||
|
||||
case CommandUpdate:
|
||||
if cmd.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
if err := comodelRS.Browse(cmd.ID).Write(cmd.Values); err != nil {
|
||||
return fmt.Errorf("orm: O2M update %s(%d): %w", f.Comodel, cmd.ID, err)
|
||||
}
|
||||
|
||||
case CommandDelete:
|
||||
if cmd.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
if err := comodelRS.Browse(cmd.ID).Unlink(); err != nil {
|
||||
return fmt.Errorf("orm: O2M delete %s(%d): %w", f.Comodel, cmd.ID, err)
|
||||
}
|
||||
|
||||
case CommandClear:
|
||||
// Delete all children linked to this parent
|
||||
children, err := comodelRS.Search(And(Leaf(f.InverseField, "=", parentID)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := children.Unlink(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessM2MCommands processes Many2many field commands after a Create/Write.
|
||||
// Mirrors: odoo/orm/fields_relational.py Many2many write logic
|
||||
//
|
||||
// Commands:
|
||||
//
|
||||
// CmdLink(id) → Add link in junction table
|
||||
// CmdUnlink(id) → Remove link from junction table
|
||||
// CmdClear() → Remove all links
|
||||
// CmdSet(ids) → Replace all links
|
||||
// CmdCreate(vals) → Create comodel record then link it
|
||||
func ProcessM2MCommands(env *Environment, f *Field, parentID int64, cmds []Command) error {
|
||||
jt := f.JunctionTable()
|
||||
col1 := f.JunctionCol1()
|
||||
col2 := f.JunctionCol2()
|
||||
|
||||
if jt == "" || col1 == "" || col2 == "" {
|
||||
return fmt.Errorf("orm: M2M field %q: cannot determine junction table", f.Name)
|
||||
}
|
||||
|
||||
for _, cmd := range cmds {
|
||||
switch cmd.Operation {
|
||||
case CommandLink:
|
||||
_, err := env.tx.Exec(env.ctx, fmt.Sprintf(
|
||||
`INSERT INTO %q (%q, %q) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
jt, col1, col2,
|
||||
), parentID, cmd.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("orm: M2M link %s: %w", f.Name, err)
|
||||
}
|
||||
|
||||
case CommandUnlink:
|
||||
_, err := env.tx.Exec(env.ctx, fmt.Sprintf(
|
||||
`DELETE FROM %q WHERE %q = $1 AND %q = $2`,
|
||||
jt, col1, col2,
|
||||
), parentID, cmd.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("orm: M2M unlink %s: %w", f.Name, err)
|
||||
}
|
||||
|
||||
case CommandClear:
|
||||
_, err := env.tx.Exec(env.ctx, fmt.Sprintf(
|
||||
`DELETE FROM %q WHERE %q = $1`,
|
||||
jt, col1,
|
||||
), parentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("orm: M2M clear %s: %w", f.Name, err)
|
||||
}
|
||||
|
||||
case CommandSet:
|
||||
// Clear then link all
|
||||
if _, err := env.tx.Exec(env.ctx, fmt.Sprintf(
|
||||
`DELETE FROM %q WHERE %q = $1`,
|
||||
jt, col1,
|
||||
), parentID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, targetID := range cmd.IDs {
|
||||
if _, err := env.tx.Exec(env.ctx, fmt.Sprintf(
|
||||
`INSERT INTO %q (%q, %q) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
jt, col1, col2,
|
||||
), parentID, targetID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
case CommandCreate:
|
||||
// Create comodel record then link
|
||||
comodelRS := env.Model(f.Comodel)
|
||||
created, err := comodelRS.Create(cmd.Values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := env.tx.Exec(env.ctx, fmt.Sprintf(
|
||||
`INSERT INTO %q (%q, %q) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
jt, col1, col2,
|
||||
), parentID, created.ID()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadM2MField reads the linked IDs for a Many2many field.
|
||||
func ReadM2MField(env *Environment, f *Field, parentIDs []int64) (map[int64][]int64, error) {
|
||||
jt := f.JunctionTable()
|
||||
col1 := f.JunctionCol1()
|
||||
col2 := f.JunctionCol2()
|
||||
|
||||
if jt == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
placeholders := make([]string, len(parentIDs))
|
||||
args := make([]interface{}, len(parentIDs))
|
||||
for i, id := range parentIDs {
|
||||
args[i] = id
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(
|
||||
`SELECT %q, %q FROM %q WHERE %q IN (%s)`,
|
||||
col1, col2, jt, col1, strings.Join(placeholders, ", "),
|
||||
)
|
||||
|
||||
rows, err := env.tx.Query(env.ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[int64][]int64)
|
||||
for rows.Next() {
|
||||
var pid, tid int64
|
||||
if err := rows.Scan(&pid, &tid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[pid] = append(result[pid], tid)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ParseCommands converts a JSON value to a slice of Commands.
|
||||
// JSON format: [[0, 0, {vals}], [4, id, 0], [6, 0, [ids]], ...]
|
||||
func ParseCommands(raw interface{}) ([]Command, bool) {
|
||||
list, ok := raw.([]interface{})
|
||||
if !ok || len(list) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Check if this looks like commands (list of lists)
|
||||
first, ok := list[0].([]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if len(first) < 2 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var cmds []Command
|
||||
for _, item := range list {
|
||||
tuple, ok := item.([]interface{})
|
||||
if !ok || len(tuple) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
op, ok := toInt64(tuple[0])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch CommandOp(op) {
|
||||
case CommandCreate: // [0, _, {vals}]
|
||||
vals := make(Values)
|
||||
if len(tuple) > 2 {
|
||||
if m, ok := tuple[2].(map[string]interface{}); ok {
|
||||
vals = m
|
||||
}
|
||||
}
|
||||
cmds = append(cmds, CmdCreate(vals))
|
||||
|
||||
case CommandUpdate: // [1, id, {vals}]
|
||||
id, _ := toInt64(tuple[1])
|
||||
vals := make(Values)
|
||||
if len(tuple) > 2 {
|
||||
if m, ok := tuple[2].(map[string]interface{}); ok {
|
||||
vals = m
|
||||
}
|
||||
}
|
||||
cmds = append(cmds, CmdUpdate(id, vals))
|
||||
|
||||
case CommandDelete: // [2, id, _]
|
||||
id, _ := toInt64(tuple[1])
|
||||
cmds = append(cmds, CmdDelete(id))
|
||||
|
||||
case CommandUnlink: // [3, id, _]
|
||||
id, _ := toInt64(tuple[1])
|
||||
cmds = append(cmds, CmdUnlink(id))
|
||||
|
||||
case CommandLink: // [4, id, _]
|
||||
id, _ := toInt64(tuple[1])
|
||||
cmds = append(cmds, CmdLink(id))
|
||||
|
||||
case CommandClear: // [5, _, _]
|
||||
cmds = append(cmds, CmdClear())
|
||||
|
||||
case CommandSet: // [6, _, [ids]]
|
||||
var ids []int64
|
||||
if len(tuple) > 2 {
|
||||
if arr, ok := tuple[2].([]interface{}); ok {
|
||||
for _, v := range arr {
|
||||
if id, ok := toInt64(v); ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cmds = append(cmds, CmdSet(ids))
|
||||
}
|
||||
}
|
||||
|
||||
if len(cmds) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return cmds, true
|
||||
}
|
||||
|
||||
func toInt64(v interface{}) (int64, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int64(n), true
|
||||
case int64:
|
||||
return n, true
|
||||
case int:
|
||||
return int64(n), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
82
pkg/orm/rules.go
Normal file
82
pkg/orm/rules.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package orm
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ApplyRecordRules adds ir.rule domain filters to a search.
|
||||
// Mirrors: odoo/addons/base/models/ir_rule.py IrRule._compute_domain()
|
||||
//
|
||||
// Rules work as follows:
|
||||
// - Global rules (no groups) are AND-ed together
|
||||
// - Group rules are OR-ed within the group set
|
||||
// - The final domain is: global_rules AND (group_rule_1 OR group_rule_2 OR ...)
|
||||
//
|
||||
// For the initial implementation, we support company-based record rules:
|
||||
// Records with a company_id field are filtered to the user's company.
|
||||
func ApplyRecordRules(env *Environment, m *Model, domain Domain) Domain {
|
||||
if env.su {
|
||||
return domain // Superuser bypasses record rules
|
||||
}
|
||||
|
||||
// Auto-apply company filter if model has company_id
|
||||
// Records where company_id = user's company OR company_id IS NULL (shared records)
|
||||
if f := m.GetField("company_id"); f != nil && f.Type == TypeMany2one {
|
||||
myCompany := Leaf("company_id", "=", env.CompanyID())
|
||||
noCompany := Leaf("company_id", "=", nil)
|
||||
companyFilter := Or(myCompany, noCompany)
|
||||
if len(domain) == 0 {
|
||||
return companyFilter
|
||||
}
|
||||
// AND the company filter with existing domain
|
||||
result := Domain{OpAnd}
|
||||
result = append(result, domain...)
|
||||
// Wrap company filter in the domain
|
||||
result = append(result, companyFilter...)
|
||||
return result
|
||||
}
|
||||
|
||||
// TODO: Load custom ir.rule records from DB and compile their domains
|
||||
// For now, only the built-in company filter is applied
|
||||
|
||||
return domain
|
||||
}
|
||||
|
||||
// CheckRecordRuleAccess verifies the user can access specific record IDs.
|
||||
// Returns an error if any record is not accessible.
|
||||
func CheckRecordRuleAccess(env *Environment, m *Model, ids []int64, perm string) error {
|
||||
if env.su || len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check company_id if the model has it
|
||||
f := m.GetField("company_id")
|
||||
if f == nil || f.Type != TypeMany2one {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Count records that match the company filter
|
||||
placeholders := make([]string, len(ids))
|
||||
args := make([]interface{}, len(ids))
|
||||
for i, id := range ids {
|
||||
args[i] = id
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||
}
|
||||
args = append(args, env.CompanyID())
|
||||
|
||||
query := fmt.Sprintf(
|
||||
`SELECT COUNT(*) FROM %q WHERE "id" IN (%s) AND ("company_id" = $%d OR "company_id" IS NULL)`,
|
||||
m.Table(),
|
||||
joinStrings(placeholders, ", "),
|
||||
len(ids)+1,
|
||||
)
|
||||
|
||||
var count int64
|
||||
err := env.tx.QueryRow(env.ctx, query, args...).Scan(&count)
|
||||
if err != nil {
|
||||
return nil // Fail open on error
|
||||
}
|
||||
|
||||
if count < int64(len(ids)) {
|
||||
return fmt.Errorf("orm: access denied by record rules on %s (company filter)", m.Name())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
69
pkg/orm/sequence.go
Normal file
69
pkg/orm/sequence.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package orm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NextByCode generates the next value for a sequence identified by its code.
|
||||
// Mirrors: odoo/addons/base/models/ir_sequence.py IrSequence.next_by_code()
|
||||
//
|
||||
// Uses PostgreSQL FOR UPDATE to ensure atomic increment under concurrency.
|
||||
// Format: prefix + LPAD(number, padding, '0') + suffix
|
||||
// Supports date interpolation in prefix/suffix: %(year)s, %(month)s, %(day)s
|
||||
func NextByCode(env *Environment, code string) (string, error) {
|
||||
var id int64
|
||||
var prefix, suffix string
|
||||
var numberNext, numberIncrement, padding int
|
||||
|
||||
err := env.tx.QueryRow(env.ctx, `
|
||||
SELECT id, COALESCE(prefix, ''), COALESCE(suffix, ''), number_next, number_increment, padding
|
||||
FROM ir_sequence
|
||||
WHERE code = $1 AND active = true
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
`, code).Scan(&id, &prefix, &suffix, &numberNext, &numberIncrement, &padding)
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("orm: sequence %q not found: %w", code, err)
|
||||
}
|
||||
|
||||
// Format the sequence value
|
||||
result := FormatSequence(prefix, suffix, numberNext, padding)
|
||||
|
||||
// Increment for next call
|
||||
_, err = env.tx.Exec(env.ctx, `
|
||||
UPDATE ir_sequence SET number_next = number_next + $1 WHERE id = $2
|
||||
`, numberIncrement, id)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("orm: sequence %q increment failed: %w", code, err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// FormatSequence formats a sequence number with prefix, suffix, and zero-padding.
|
||||
func FormatSequence(prefix, suffix string, number, padding int) string {
|
||||
prefix = InterpolateDate(prefix)
|
||||
suffix = InterpolateDate(suffix)
|
||||
|
||||
numStr := fmt.Sprintf("%d", number)
|
||||
if padding > 0 && len(numStr) < padding {
|
||||
numStr = strings.Repeat("0", padding-len(numStr)) + numStr
|
||||
}
|
||||
|
||||
return prefix + numStr + suffix
|
||||
}
|
||||
|
||||
// InterpolateDate replaces Odoo-style date placeholders in a string.
|
||||
// Supports: %(year)s, %(month)s, %(day)s, %(y)s (2-digit year)
|
||||
func InterpolateDate(s string) string {
|
||||
now := time.Now()
|
||||
s = strings.ReplaceAll(s, "%(year)s", now.Format("2006"))
|
||||
s = strings.ReplaceAll(s, "%(y)s", now.Format("06"))
|
||||
s = strings.ReplaceAll(s, "%(month)s", now.Format("01"))
|
||||
s = strings.ReplaceAll(s, "%(day)s", now.Format("02"))
|
||||
return s
|
||||
}
|
||||
209
pkg/orm/types.go
Normal file
209
pkg/orm/types.go
Normal file
@@ -0,0 +1,209 @@
|
||||
// Package orm implements the Odoo ORM in Go.
|
||||
// Mirrors: odoo/orm/models.py, odoo/orm/fields.py
|
||||
package orm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FieldType mirrors Odoo's field type system.
|
||||
// See: odoo/orm/fields.py
|
||||
type FieldType int
|
||||
|
||||
const (
|
||||
TypeChar FieldType = iota
|
||||
TypeText
|
||||
TypeHTML
|
||||
TypeInteger
|
||||
TypeFloat
|
||||
TypeMonetary
|
||||
TypeBoolean
|
||||
TypeDate
|
||||
TypeDatetime
|
||||
TypeBinary
|
||||
TypeSelection
|
||||
TypeJson
|
||||
TypeMany2one
|
||||
TypeOne2many
|
||||
TypeMany2many
|
||||
TypeReference
|
||||
TypeProperties
|
||||
)
|
||||
|
||||
func (ft FieldType) String() string {
|
||||
names := [...]string{
|
||||
"char", "text", "html", "integer", "float", "monetary",
|
||||
"boolean", "date", "datetime", "binary", "selection",
|
||||
"json", "many2one", "one2many", "many2many", "reference", "properties",
|
||||
}
|
||||
if int(ft) < len(names) {
|
||||
return names[ft]
|
||||
}
|
||||
return fmt.Sprintf("unknown(%d)", ft)
|
||||
}
|
||||
|
||||
// SQLType returns the PostgreSQL column type for this field type.
|
||||
// Mirrors: odoo/orm/fields.py column_type property
|
||||
func (ft FieldType) SQLType() string {
|
||||
switch ft {
|
||||
case TypeChar:
|
||||
return "varchar"
|
||||
case TypeText, TypeHTML:
|
||||
return "text"
|
||||
case TypeInteger:
|
||||
return "int4"
|
||||
case TypeFloat:
|
||||
return "numeric"
|
||||
case TypeMonetary:
|
||||
return "numeric"
|
||||
case TypeBoolean:
|
||||
return "bool"
|
||||
case TypeDate:
|
||||
return "date"
|
||||
case TypeDatetime:
|
||||
return "timestamp without time zone"
|
||||
case TypeBinary:
|
||||
return "bytea"
|
||||
case TypeSelection:
|
||||
return "varchar"
|
||||
case TypeJson, TypeProperties:
|
||||
return "jsonb"
|
||||
case TypeMany2one:
|
||||
return "int4"
|
||||
case TypeReference:
|
||||
return "varchar"
|
||||
case TypeOne2many, TypeMany2many:
|
||||
return "" // no column, computed
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// IsRelational returns true for relational field types.
|
||||
func (ft FieldType) IsRelational() bool {
|
||||
return ft == TypeMany2one || ft == TypeOne2many || ft == TypeMany2many
|
||||
}
|
||||
|
||||
// IsStored returns true if this field type has a database column.
|
||||
func (ft FieldType) IsStored() bool {
|
||||
return ft != TypeOne2many && ft != TypeMany2many
|
||||
}
|
||||
|
||||
// ModelType mirrors Odoo's model categories.
|
||||
// See: odoo/orm/models.py BaseModel._auto
|
||||
type ModelType int
|
||||
|
||||
const (
|
||||
// ModelRegular corresponds to odoo.models.Model (_auto=True, _abstract=False)
|
||||
ModelRegular ModelType = iota
|
||||
// ModelTransient corresponds to odoo.models.TransientModel (_transient=True)
|
||||
ModelTransient
|
||||
// ModelAbstract corresponds to odoo.models.AbstractModel (_auto=False, _abstract=True)
|
||||
ModelAbstract
|
||||
)
|
||||
|
||||
// OnDelete mirrors Odoo's ondelete parameter for Many2one fields.
|
||||
// See: odoo/orm/fields_relational.py Many2one.ondelete
|
||||
type OnDelete string
|
||||
|
||||
const (
|
||||
OnDeleteSetNull OnDelete = "set null"
|
||||
OnDeleteRestrict OnDelete = "restrict"
|
||||
OnDeleteCascade OnDelete = "cascade"
|
||||
)
|
||||
|
||||
// Value represents any value that can be stored in or read from a field.
|
||||
// Mirrors Odoo's dynamic typing for field values.
|
||||
type Value interface{}
|
||||
|
||||
// Values is a map of field names to values, used for create/write operations.
|
||||
// Mirrors Odoo's vals dict passed to create() and write().
|
||||
type Values = map[string]interface{}
|
||||
|
||||
// SelectionItem represents one option in a Selection field.
|
||||
// Mirrors: odoo/orm/fields.py Selection.selection items
|
||||
type SelectionItem struct {
|
||||
Value string
|
||||
Label string
|
||||
}
|
||||
|
||||
// NullTime wraps time.Time to handle NULL dates from PostgreSQL.
|
||||
type NullTime struct {
|
||||
Time time.Time
|
||||
Valid bool
|
||||
}
|
||||
|
||||
// NullInt wraps int64 to handle NULL integers (e.g., Many2one FK).
|
||||
type NullInt struct {
|
||||
Int64 int64
|
||||
Valid bool
|
||||
}
|
||||
|
||||
// NullString wraps string to handle NULL text fields.
|
||||
type NullString struct {
|
||||
String string
|
||||
Valid bool
|
||||
}
|
||||
|
||||
// Registry is the global model registry.
|
||||
// Mirrors: odoo/orm/registry.py Registry
|
||||
// Holds all registered models, keyed by model name (e.g., "res.partner").
|
||||
var Registry = &ModelRegistry{
|
||||
models: make(map[string]*Model),
|
||||
}
|
||||
|
||||
// ModelRegistry manages all model definitions.
|
||||
// Thread-safe for concurrent module loading.
|
||||
type ModelRegistry struct {
|
||||
mu sync.RWMutex
|
||||
models map[string]*Model
|
||||
loaded bool
|
||||
}
|
||||
|
||||
// Get returns a model by name, or nil if not found.
|
||||
func (r *ModelRegistry) Get(name string) *Model {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.models[name]
|
||||
}
|
||||
|
||||
// MustGet returns a model by name, panics if not found.
|
||||
func (r *ModelRegistry) MustGet(name string) *Model {
|
||||
m := r.Get(name)
|
||||
if m == nil {
|
||||
panic(fmt.Sprintf("orm: model %q not registered", name))
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Register adds a model to the registry.
|
||||
func (r *ModelRegistry) Register(m *Model) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.models[m.name] = m
|
||||
}
|
||||
|
||||
// All returns all registered model names.
|
||||
func (r *ModelRegistry) All() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
names := make([]string, 0, len(r.models))
|
||||
for name := range r.models {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// Models returns all registered models.
|
||||
func (r *ModelRegistry) Models() map[string]*Model {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
// Return a copy to avoid race conditions
|
||||
result := make(map[string]*Model, len(r.models))
|
||||
for k, v := range r.models {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user