Batch 1 (quick-fixes): - Field.Copy attribute + IsCopyable() method for copy control - Constraints now run in Write() (was only Create — bug fix) - Readonly fields silently skipped in Write() - active_test: auto-filter archived records in Search() Batch 2 (Related field write-back): - preprocessRelatedWrites() follows FK chain and writes to target model - Enables Settings page to edit company name/address/etc. - Loop protection via _write_related_depth context counter Batch 3 (_inherits delegation): - SetupAllInherits() generates Related fields from parent models - preprocessInheritsCreate() auto-creates parent records on Create - Declared on res.users, res.company, product.product - Called in LoadModules before compute setup Batch 4 (Copy method): - Recordset.Copy(defaults) with blacklist, IsCopyable check - M2M re-linking, rec_name "(copy)" suffix - Replaces simplified copy case in server dispatch Batch 5 (Onchange compute): - RunOnchangeComputes() triggers dependent computes on field change - Virtual record (ID=-1) with client values in cache - Integrated into onchange RPC handler Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1113 lines
27 KiB
Go
1113 lines
27 KiB
Go
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
|
|
|
|
// Handle _inherits: separate parent vals and create/update parent records
|
|
if err := preprocessInheritsCreate(rs.env, m, vals); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Phase 1: Apply defaults for missing fields
|
|
ApplyDefaults(m, vals)
|
|
|
|
// Apply dynamic defaults from model's DefaultGet hook (e.g., DB lookups)
|
|
if m.DefaultGet != nil {
|
|
dynDefaults := m.DefaultGet(rs.env, nil)
|
|
for k, v := range dynDefaults {
|
|
if _, exists := vals[k]; !exists {
|
|
vals[k] = v
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
// Odoo sends false for empty fields; convert to nil for non-boolean types
|
|
val = sanitizeFieldValue(f, val)
|
|
// Skip nil values (let DB use column default)
|
|
if val == nil {
|
|
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
|
|
}
|
|
|
|
// preprocessRelatedWrites handles write-back for Related fields.
|
|
// When writing to a Related field (e.g., "company_id.name"), this function
|
|
// follows the FK chain and writes to the target model instead.
|
|
// Mirrors: odoo/orm/fields.py _inverse_related()
|
|
func preprocessRelatedWrites(env *Environment, m *Model, ids []int64, vals Values) error {
|
|
// Prevent infinite recursion
|
|
depth := 0
|
|
if d, ok := env.context["_write_related_depth"]; ok {
|
|
if di, ok := d.(int); ok {
|
|
depth = di
|
|
}
|
|
}
|
|
if depth > 5 {
|
|
return nil // Safety: max recursion depth
|
|
}
|
|
|
|
var relatedFields []string
|
|
for fieldName := range vals {
|
|
f := m.GetField(fieldName)
|
|
if f != nil && f.Related != "" && !f.IsStored() {
|
|
relatedFields = append(relatedFields, fieldName)
|
|
}
|
|
}
|
|
|
|
if len(relatedFields) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Read FK values for the records being written
|
|
// We need the FK IDs to know which parent records to update
|
|
for _, fieldName := range relatedFields {
|
|
f := m.GetField(fieldName)
|
|
parts := strings.Split(f.Related, ".")
|
|
if len(parts) != 2 {
|
|
delete(vals, fieldName) // Can't handle multi-hop yet, skip
|
|
continue
|
|
}
|
|
fkFieldName := parts[0]
|
|
targetFieldName := parts[1]
|
|
|
|
fkDef := m.GetField(fkFieldName)
|
|
if fkDef == nil || fkDef.Type != TypeMany2one || fkDef.Comodel == "" {
|
|
delete(vals, fieldName)
|
|
continue
|
|
}
|
|
|
|
value := vals[fieldName]
|
|
delete(vals, fieldName) // Remove from vals — no local column
|
|
|
|
// Read FK IDs for all records
|
|
var fkIDs []int64
|
|
for _, id := range ids {
|
|
var fkID *int64
|
|
env.tx.QueryRow(env.ctx,
|
|
fmt.Sprintf(`SELECT %q FROM %q WHERE id = $1`, fkDef.Column(), m.Table()),
|
|
id,
|
|
).Scan(&fkID)
|
|
if fkID != nil && *fkID > 0 {
|
|
fkIDs = append(fkIDs, *fkID)
|
|
}
|
|
}
|
|
|
|
if len(fkIDs) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Deduplicate
|
|
seen := make(map[int64]bool)
|
|
var uniqueIDs []int64
|
|
for _, id := range fkIDs {
|
|
if !seen[id] {
|
|
seen[id] = true
|
|
uniqueIDs = append(uniqueIDs, id)
|
|
}
|
|
}
|
|
|
|
// Write to target model with increased depth
|
|
depthEnv := env.WithContext(map[string]interface{}{
|
|
"_write_related_depth": depth + 1,
|
|
})
|
|
targetRS := &Recordset{env: depthEnv, model: Registry.MustGet(fkDef.Comodel), ids: uniqueIDs}
|
|
if err := targetRS.Write(Values{targetFieldName: value}); err != nil {
|
|
return fmt.Errorf("related write-back %s.%s → %s.%s: %w",
|
|
m.Name(), fieldName, fkDef.Comodel, targetFieldName, err)
|
|
}
|
|
}
|
|
|
|
return 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
|
|
|
|
// Handle Related field write-back (must happen before building SET clauses)
|
|
if err := preprocessRelatedWrites(rs.env, m, rs.ids, vals); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, name := range m.fieldOrder {
|
|
f := m.fields[name]
|
|
if name == "id" || !f.IsStored() {
|
|
continue
|
|
}
|
|
// Skip readonly fields (like Python Odoo, silently ignore)
|
|
if f.Readonly && name != "write_uid" && name != "write_date" {
|
|
continue
|
|
}
|
|
val, exists := vals[name]
|
|
if !exists {
|
|
continue
|
|
}
|
|
|
|
if name == "write_date" {
|
|
setClauses = append(setClauses, `"write_date" = NOW()`)
|
|
continue
|
|
}
|
|
|
|
// Odoo sends false for empty fields; convert to nil for non-boolean types
|
|
val = sanitizeFieldValue(f, val)
|
|
|
|
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
|
|
}
|
|
|
|
// Run constraints after write (mirrors Create constraint check)
|
|
for _, constraint := range m.Constraints {
|
|
for _, id := range rs.ids {
|
|
if err := constraint(rs.Browse(id)); 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)
|
|
wantDisplayName := false // Whether display_name was requested
|
|
|
|
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 name == "display_name" && f.Compute != "" && !f.Store {
|
|
wantDisplayName = true
|
|
continue
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
// If display_name is requested, ensure the rec_name field is fetched from DB
|
|
recName := m.recName
|
|
if wantDisplayName {
|
|
recNameAlreadyFetched := false
|
|
for _, sf := range storedFields {
|
|
if sf == recName {
|
|
recNameAlreadyFetched = true
|
|
break
|
|
}
|
|
}
|
|
if !recNameAlreadyFetched {
|
|
recF := m.GetField(recName)
|
|
if recF != nil && recF.IsStored() {
|
|
columns = append(columns, fmt.Sprintf("%q", recF.Column()))
|
|
storedFields = append(storedFields, recName)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Fetch without ORDER BY — we'll reorder to match rs.ids below.
|
|
// This preserves the caller's intended order (e.g., from Search with a custom ORDER).
|
|
query := fmt.Sprintf(
|
|
`SELECT %s FROM %q WHERE "id" IN (%s)`,
|
|
strings.Join(columns, ", "),
|
|
m.table,
|
|
strings.Join(idPlaceholders, ", "),
|
|
)
|
|
|
|
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()
|
|
|
|
// Collect results keyed by ID so we can reorder them.
|
|
resultsByID := make(map[int64]Values, len(rs.ids))
|
|
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)
|
|
}
|
|
}
|
|
if id, ok := toRecordID(record["id"]); ok {
|
|
resultsByID[id] = record
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Reorder results to match the original rs.ids order.
|
|
results := make([]Values, 0, len(rs.ids))
|
|
for _, id := range rs.ids {
|
|
if rec, ok := resultsByID[id]; ok {
|
|
results = append(results, rec)
|
|
}
|
|
}
|
|
|
|
// Post-fetch: compute display_name from the rec_name field value.
|
|
// Mirrors: odoo/orm/models.py BaseModel._compute_display_name()
|
|
if wantDisplayName {
|
|
for _, rec := range results {
|
|
if nameVal, ok := rec[recName]; ok {
|
|
switch v := nameVal.(type) {
|
|
case string:
|
|
rec["display_name"] = v
|
|
case nil:
|
|
rec["display_name"] = ""
|
|
default:
|
|
rec["display_name"] = fmt.Sprintf("%v", v)
|
|
}
|
|
} else {
|
|
rec["display_name"] = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
|
|
// Auto-filter archived records unless active_test=false in context
|
|
// Mirrors: odoo/orm/models.py BaseModel._where_calc()
|
|
if activeField := m.GetField("active"); activeField != nil {
|
|
activeTest := true
|
|
if v, ok := rs.env.context["active_test"]; ok {
|
|
if b, ok := v.(bool); ok {
|
|
activeTest = b
|
|
}
|
|
}
|
|
if activeTest {
|
|
activeLeaf := Leaf("active", "=", true)
|
|
if len(domain) == 0 {
|
|
domain = Domain{activeLeaf}
|
|
} else {
|
|
domain = append(Domain{OpAnd}, append(domain, activeLeaf)...)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply record rules (e.g., multi-company filter)
|
|
domain = ApplyRecordRules(rs.env, m, domain)
|
|
|
|
// Compile domain to SQL
|
|
compiler := &DomainCompiler{model: m, env: rs.env}
|
|
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, env: rs.env}
|
|
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, _ := toRecordID(rec["id"])
|
|
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
|
|
}
|
|
|
|
// Copy duplicates a singleton record, returning the new record.
|
|
// Mirrors: odoo/orm/models.py BaseModel.copy()
|
|
//
|
|
// new_partner = partner.Copy(orm.Values{"name": "New Name"})
|
|
func (rs *Recordset) Copy(defaults Values) (*Recordset, error) {
|
|
if err := rs.Ensure(); err != nil {
|
|
return nil, err
|
|
}
|
|
m := rs.model
|
|
|
|
// Read all stored fields
|
|
records, err := rs.Read(nil)
|
|
if err != nil || len(records) == 0 {
|
|
return nil, fmt.Errorf("orm: copy: record %s(%d) not found", m.name, rs.ids[0])
|
|
}
|
|
original := records[0]
|
|
|
|
// Blacklisted fields (never copied)
|
|
blacklist := map[string]bool{
|
|
"id": true, "create_uid": true, "create_date": true,
|
|
"write_uid": true, "write_date": true, "display_name": true,
|
|
"password": true, "password_crypt": true,
|
|
}
|
|
|
|
// Also blacklist _inherits FK fields (parent will be copied separately)
|
|
for _, fkField := range m.inherits {
|
|
blacklist[fkField] = true
|
|
}
|
|
|
|
newVals := make(Values)
|
|
|
|
for _, name := range m.fieldOrder {
|
|
if blacklist[name] {
|
|
continue
|
|
}
|
|
f := m.fields[name]
|
|
if f == nil || !f.IsCopyable() || !f.IsStored() {
|
|
continue
|
|
}
|
|
|
|
val, exists := original[name]
|
|
if !exists || val == nil {
|
|
continue
|
|
}
|
|
|
|
switch f.Type {
|
|
case TypeMany2many:
|
|
// Re-link existing M2M entries (don't duplicate targets)
|
|
if ids, ok := val.([]int64); ok && len(ids) > 0 {
|
|
cmds := make([]interface{}, len(ids))
|
|
for i, id := range ids {
|
|
cmds[i] = []interface{}{4, id, 0} // Command LINK
|
|
}
|
|
newVals[name] = cmds
|
|
}
|
|
case TypeOne2many:
|
|
// Skip — O2M records are NOT copied by default (copy=false)
|
|
continue
|
|
default:
|
|
newVals[name] = val
|
|
}
|
|
}
|
|
|
|
// Append "(copy)" to rec_name field
|
|
recName := m.recName
|
|
if recName == "" {
|
|
recName = "name"
|
|
}
|
|
if nameVal, ok := newVals[recName].(string); ok {
|
|
newVals[recName] = nameVal + " (copy)"
|
|
}
|
|
|
|
// Apply user-provided defaults (override copied values)
|
|
if defaults != nil {
|
|
for k, v := range defaults {
|
|
newVals[k] = v
|
|
}
|
|
}
|
|
|
|
return rs.Create(newVals)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// sanitizeFieldValue converts Odoo's false/empty values to Go-native types
|
|
// suitable for PostgreSQL. Odoo sends false for empty string/numeric/relational
|
|
// fields; PostgreSQL rejects false for varchar/int columns.
|
|
// Mirrors: odoo/orm/fields.py convert_to_column()
|
|
func sanitizeFieldValue(f *Field, val interface{}) interface{} {
|
|
if val == nil {
|
|
return nil
|
|
}
|
|
|
|
// Handle the Odoo false → nil conversion for non-boolean fields
|
|
if b, ok := val.(bool); ok && !b {
|
|
if f.Type == TypeBoolean {
|
|
return false // Keep false for boolean fields
|
|
}
|
|
return nil // Convert false → NULL for all other types
|
|
}
|
|
|
|
// Handle float→int conversion for integer/M2O fields
|
|
switch f.Type {
|
|
case TypeInteger, TypeMany2one:
|
|
if fv, ok := val.(float64); ok {
|
|
return int64(fv)
|
|
}
|
|
}
|
|
|
|
return val
|
|
}
|