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: translations for non-English languages // Mirrors: odoo/orm/models.py BaseModel._read() translation lookup if rs.env.Lang() != "en_US" && rs.env.Lang() != "" { for _, fname := range storedFields { f := m.GetField(fname) if f == nil || !f.Translate { continue } for _, rec := range results { srcVal, _ := rec[fname].(string) if srcVal == "" { continue } var translated string err := rs.env.tx.QueryRow(rs.env.ctx, `SELECT value FROM ir_translation WHERE lang = $1 AND src = $2 AND value != '' LIMIT 1`, rs.env.Lang(), srcVal, ).Scan(&translated) if err == nil && translated != "" { rec[fname] = translated } } } } // 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 // 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)...) } } } 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 }