ORM: - SQL Constraints support (Model.AddSQLConstraint, applied in InitDatabase) - Translatable field Read (ir_translation lookup for non-en_US) - active_test filter in SearchCount + ReadGroup (consistency with Search) - Environment.Ref() improved (format validation, parameterized query) Server: - /web/action/run endpoint (server action execution stub) - /web/model/get_definitions (field metadata for multiple models) - Binary field serving rewritten: reads from DB, falls back to SVG with record initial (fixes avatar/logo rendering) Business modules deepened: - Account: action_post validation (partner, lines), sequence numbering (JOURNAL/YYYY/NNNN), action_register_payment, remove_move_reconcile - Sale: action_cancel, action_draft, action_view_invoice - Purchase: button_draft - Stock: action_cancel on picking - CRM: action_set_won_rainbowman, convert_opportunity - HR: hr.contract model (employee, wage, dates, state) - Project: action_blocked, task stage seed data Views: - Cancel/Reset buttons in sale.form header - Register Payment button in invoice.form (visible when posted+unpaid) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
270 lines
6.8 KiB
Go
270 lines
6.8 KiB
Go
package orm
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"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) {
|
|
// Try direct integer ID (for programmatic use) — reject since model is unknown
|
|
if id, err := strconv.ParseInt(xmlID, 10, 64); err == nil {
|
|
return nil, fmt.Errorf("orm: ref requires module.name format, not bare ID %d", id)
|
|
}
|
|
|
|
// Query ir_model_data for the external ID
|
|
var resModel string
|
|
var resID int64
|
|
|
|
parts := strings.SplitN(xmlID, ".", 2)
|
|
if len(parts) != 2 {
|
|
return nil, fmt.Errorf("orm: ref %q must be module.name format", xmlID)
|
|
}
|
|
|
|
err := env.tx.QueryRow(env.ctx,
|
|
`SELECT model, res_id FROM ir_model_data WHERE module = $1 AND name = $2 LIMIT 1`,
|
|
parts[0], parts[1],
|
|
).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)
|
|
}
|
|
}
|