Odoo ORM 全面深度解析

一、核心概念与基础操作

欢迎来到Odoo开发的世界!Odoo是一个功能强大的开源企业资源规划(ERP)系统,其灵活性和可扩展性在很大程度上归功于其独特的对象关系映射(ORM)层。对于初学者而言,理解ORM是掌握Odoo开发的第一步,也是至关重要的一步。本节将带你深入浅出地了解Odoo ORM的本质、优势,并教会你如何执行最基本的数据操作(CRUD)。


一、Odoo ORM 核心概念

1. ORM 的本质:连接代码与数据库的桥梁 

ORM(Object-Relational Mapping,对象关系映射) 是一种编程技术,它在关系型数据库和面向对象编程语言之间建立起一座桥梁。简单来说:

  • 数据库 (Relational Database):以表格(Table)的形式存储数据,表格由行(Row)和列(Column)组成。我们通常使用SQL语言来操作数据库。
  • 面向对象编程 (Object-Oriented Programming):以对象(Object)为基本单位来组织代码,对象拥有属性(Attribute)和方法(Method)。在Odoo中,我们主要使用Python。

Odoo ORM 允许你将数据库中的表映射为Python中的类(称为 模型 (Model)),将表中的行映射为类的实例(称为 记录 (Record)),将表中的列映射为实例的属性。

这样,开发者就可以通过操作Python对象来间接操作数据库,而无需直接编写复杂的SQL查询语句。Odoo的ORM会自动将你的Python代码操作转换为相应的SQL语句在后台执行。

示例:

假设数据库中有一个名为 res_partner 的表,用于存储联系人信息,它有 nameemail 两个字段。

  • 在Odoo ORM中:
    • 会有一个名为 res.partner 的Python模型(类)。
    • 当你创建一个新的联系人时,你会创建一个 res.partner 模型的实例。
    • 这个实例的 nameemail 属性就对应数据库表中相应记录的字段值。

2. 模型 (Models):业务对象的蓝图 

在Odoo中,模型 是核心构建块。每个模型都是一个Python类,它继承自Odoo提供的基类(通常是 models.Model)。这个类定义了一个特定业务对象(如产品、订单、联系人)的结构和行为。

  • 结构 (Fields):模型通过定义字段(Fields)来描述其数据结构。字段有多种类型,如字符型 (Char)、整型 (Integer)、日期型 (Date)、关系型 (Many2one, One2many, Many2many) 等。这些字段直接对应数据库表的列。
  • 行为 (Methods):模型可以包含Python方法,用于定义业务逻辑、计算、验证等。

3. 记录集 (Recordsets):记录的集合 

当你通过ORM查询或操作数据时,通常会得到一个或多个记录。Odoo将这些记录组织成一个 记录集 (Recordset)。记录集是模型实例的有序集合,你可以像操作Python列表一样遍历它,也可以对整个记录集执行操作(如批量更新、删除)。

  • 单个记录:记录集可以只包含一个记录。
  • 空记录集:如果查询没有找到任何结果,会返回一个空的记录集。

4. 环境 (Environment - env):操作的上下文和入口 

在Odoo中,env 对象(Environment)是一个至关重要的概念。它封装了当前操作的上下文信息,包括:

  • 数据库游标 (Cursor):用于实际执行SQL查询。
  • 当前用户 (User ID):用于权限检查。
  • 上下文 (Context):一个Python字典,可以传递额外的信息(如语言、时区等)。
  • 模型注册表 (Model Registry):允许你访问系统中的任何模型。

在模型的方法内部,你可以通过 self.env 来访问当前环境。要访问任何一个模型(比如 res.partner 模型),你需要通过环境来获取它:

# 在一个模型的方法内部
partner_model = self.env['res.partner']
# 这里的 partner_model 就是一个可以直接用来操作 res.partner 数据的入口点

self.env['model.name'] 是与特定模型交互的标准入口点。它返回一个该模型的空记录集,但你可以立即在其上调用 create(), search(), browse() 等方法。


二、使用 Odoo ORM 的主要优势

为什么不直接写SQL?因为Odoo ORM提供了诸多便利:

  1. 代码可读性与可维护性 (Readability & Maintainability)
    • 使用Python对象和方法来操作数据,比嵌入大量SQL字符串更直观,更易于理解和维护。
    • 业务逻辑与数据访问逻辑更紧密地结合在模型中。
  2. 数据库无关性 (Database Independence)
    • Odoo目前主要支持PostgreSQL。但理论上,ORM层可以抽象掉底层数据库的差异。如果Odoo未来决定支持其他数据库,你的Python代码大部分情况下无需修改,ORM会自动处理不同数据库SQL方言的转换。
  3. 安全性 (Security)
    • ORM有助于防止SQL注入攻击。当你使用ORM方法传递参数时,Odoo会正确地处理这些参数的转义,而不是简单地将用户输入拼接到SQL查询中。
    • Odoo的访问控制(Access Control Lists, ACLs)和记录规则(Record Rules)是基于ORM层面实现的,确保用户只能访问和修改他们被授权的数据。
  4. 开发效率 (Development Efficiency)
    • ORM提供了许多高级功能,如字段默认值、计算字段、约束、关系字段的自动处理等,这些都大大减少了需要编写的样板代码。
    • ORM方法通常是链式的,可以进行优雅的数据查询和操作。
  5. 缓存机制 (Caching)
    • Odoo ORM内置了缓存机制,对于频繁读取的数据,可以显著提高性能。
  6. 扩展性 (Extensibility)
    • Odoo的模型可以通过继承(_inherit)轻松扩展,添加新字段或修改现有方法,这对于模块化开发至关重要。

三、Odoo ORM 基础操作 (CRUD)

CRUD 代表了数据持久化的四个基本操作:创建 (Create)、读取 (Read)、更新 (Update)、删除 (Delete)。下面我们将逐一介绍如何使用Odoo ORM执行这些操作。

在所有示例中,我们假设 self 是某个Odoo模型的一个实例,因此 self.env 是可用的。我们将以 res.partner(联系人)模型为例。

准备:获取模型入口点

在进行任何操作之前,你需要获取目标模型的"句柄"或"入口点"。这是通过 self.env['model.name'] 实现的。

# 获取 res.partner 模型的入口点
Partner = self.env['res.partner']

现在,Partner 变量就可以用来调用 create(), search(), browse() 等方法了。

1. 创建记录 (Create) - create()

create() 方法用于在数据库中创建一条新的记录。

  • 参数:一个字典,键是字段名,值是对应字段的值。
  • 返回:一个包含新创建记录的记录集(只包含一条记录)。
# 示例:创建一个新的联系人
new_partner_vals = {
    'name': '中国智造有限公司',
    'email': 'contact@madeinchina.com',
    'phone': '010-12345678',
    'is_company': True, # 这是一个公司类型的联系人
    'country_id': self.env.ref('base.cn').id, # 假设我们知道中国的记录ID或XML ID
}

# 调用 create 方法创建记录
new_partner_record = Partner.create(new_partner_vals)

# new_partner_record 是一个包含新创建的联系人信息的记录集
print(f"创建成功!新联系人ID: {new_partner_record.id}, 名称: {new_partner_record.name}")

# 如果你只需要新记录的ID
# new_partner_id = Partner.create(new_partner_vals).id

注释:

  • 'name', 'email', 'phone', 'is_company', 'country_id' 都是 res.partner 模型中定义的字段名。
  • self.env.ref('base.cn') 是一个获取已知XML ID对应记录的方法,这里用来获取“中国”这个国家记录的ID。如果国家记录不存在,country_id 可以省略或设为 False
  • create() 方法执行后,一条新的记录就被插入到 res_partner 表中了。

2. 读取记录 (Read)

读取记录主要有两种方法:search() 用于根据条件查找记录,browse() 用于根据ID直接获取记录。

A. 查找记录 - search()

search() 方法用于根据指定的条件(称为 域 (Domain))从数据库中检索记录。

  • 参数
    • args (Domain):一个列表,定义了过滤条件。Domain的格式是一个由元组组成的列表,每个元组通常是 ('field_name', 'operator', value) 的形式。
    • limit (Integer):可选参数,限制返回记录的数量。
    • order (String):可选参数,指定排序方式,如 'name asc'
    • count (Boolean): 如果为 True,则返回满足条件的记录数量,而不是记录集。
  • 返回:一个满足条件的记录集。
# 示例1:查找所有公司类型的联系人
company_partners = Partner.search([('is_company', '=', True)])
print(f"找到 {len(company_partners)} 个公司类型的联系人:")
for partner in company_partners:
    print(f"  ID: {partner.id}, 名称: {partner.name}")

# 示例2:查找名称包含 "中国" 并且邮箱已设置的联系人,最多返回5条
china_contacts_with_email = Partner.search(
    [
        ('name', 'ilike', '中国'), # 'ilike' 表示不区分大小写的模糊匹配
        ('email', '!=', False)    # 检查邮箱字段是否已设置(不为NULL或空)
    ],
    limit=5,
    order='create_date desc' # 按创建日期降序排列
)
print(f"\n找到 {len(china_contacts_with_email)} 个名称含'中国'且有邮箱的联系人 (最多5条):")
for partner in china_contacts_with_email:
    print(f"  ID: {partner.id}, 名称: {partner.name}, 邮箱: {partner.email}")

# 示例3:统计所有中国客户的数量
china_country_id = self.env.ref('base.cn').id
count_china_customers = Partner.search_count([('country_id', '=', china_country_id)])
# 或者使用 search() 的 count 参数
# count_china_customers = Partner.search([('country_id', '=', china_country_id)], count=True)
print(f"\n共有 {count_china_customers} 位中国客户。")

Domain 常用操作符:

  • =:等于
  • !=:不等于
  • >:大于
  • <:小于
  • >=:大于等于
  • <=:小于等于
  • in:在一个列表中
  • not in:不在一个列表中
  • like:模糊匹配 (区分大小写, 使用 %作为通配符)
  • ilike:模糊匹配 (不区分大小写, 使用 %作为通配符)
  • child_of:用于层级结构的父子关系查询。
  • parent_of: (较少直接使用,通常通过反向关系)

多个条件默认是 AND 关系。可以使用 '&' (AND), '|' (OR), '!' (NOT) 作为列表中的元素来构建更复杂的逻辑,例如 ['|', ('name', '=', 'A'), ('name', '=', 'B')] 表示 name = 'A' OR name = 'B'

B. 根据ID读取记录 - browse()

如果你已经知道记录的ID(s),可以使用 browse() 方法直接获取这些记录。

  • 参数:一个整数ID,或者一个ID的列表/元组。
  • 返回:一个包含指定ID记录的记录集。如果某个ID不存在,它在记录集中会表现为一个“无效”记录。
# 示例1:通过单个ID获取记录
partner_id_to_browse = 10 # 假设ID为10的联系人存在
partner_10 = Partner.browse(partner_id_to_browse)

if partner_10.exists(): # 检查记录是否存在且可读
    print(f"\n通过ID {partner_id_to_browse} 获取到的联系人: {partner_10.name}")
else:
    print(f"\nID为 {partner_id_to_browse} 的联系人不存在或无权访问。")

# 示例2:通过ID列表获取多个记录
ids_to_browse = [1, 5, new_partner_record.id] # 使用之前创建的新联系人ID
multiple_partners = Partner.browse(ids_to_browse)

print(f"\n通过ID列表 {ids_to_browse} 获取到的联系人:")
for partner in multiple_partners:
    if partner.exists():
        print(f"  ID: {partner.id}, 名称: {partner.name}")
    else:
        print(f"  某个ID对应的记录不存在或不可访问。")

注意browse()search([('id', 'in', ids_list)]) 效率更高,因为它直接从缓存或数据库中按主键获取记录。

C. 读取字段值

一旦你获取了一个记录集(无论是通过 create, search还是 browse),你就可以访问其字段值,就像访问Python对象的属性一样。

# 假设 a_partner 是一个包含单条记录的记录集
# a_partner = Partner.browse(some_id)
# 或者
# a_partner = Partner.search([('name', '=', '特定名称')], limit=1)

if a_partner.exists():
    print(f"联系人名称: {a_partner.name}")
    print(f"联系人邮箱: {a_partner.email}")
    print(f"联系人电话: {a_partner.phone}")
    # 对于Many2one字段,如 country_id,直接访问会得到一个该关联模型的记录集
    if a_partner.country_id:
        print(f"联系人国家: {a_partner.country_id.name}") # .name 获取关联记录的名称字段
    else:
        print(f"联系人国家: 未设置")

如果记录集包含多条记录,直接访问属性会报错。你需要先遍历记录集。

# all_partners 是一个包含多条记录的记录集
# all_partners = Partner.search([])
for partner_record in all_partners:
    print(f"ID: {partner_record.id}, 名称: {partner_record.name}")

3. 更新记录 (Update) - write()

write() 方法用于修改已存在记录的一个或多个字段值。它作用于一个记录集上。

  • 参数:一个字典,键是需要更新的字段名,值是新的字段值。
  • 返回True (如果操作成功)。
  • 注意write() 方法会直接修改数据库中的记录。
# 假设我们要更新之前创建的 new_partner_record
if new_partner_record.exists():
    update_values = {
        'phone': '021-87654321',
        'comment': '这是一个更新后的备注信息。',
        'website': 'https://www.example-china.com'
    }
    new_partner_record.write(update_values)
    print(f"\n记录 {new_partner_record.id} ({new_partner_record.name}) 已更新。")
    print(f"更新后的电话: {new_partner_record.phone}")
    print(f"更新后的备注: {new_partner_record.comment}")

# 批量更新:将所有在北京的公司的类型都标记为 'other'(假设 'company_type' 字段存在)
beijing_country_id = self.env.ref('base.state_cn_bj').country_id.id # 假设有北京的省份记录
beijing_companies = Partner.search([
    ('is_company', '=', True),
    ('country_id', '=', beijing_country_id), # 查找中国的公司
    ('state_id', '=', self.env.ref('base.state_cn_bj').id) # 并且省份是北京
])

if beijing_companies:
    beijing_companies.write({'company_type': 'other'}) # 'company_type' 是 res.partner 的一个字段
    print(f"\n已将 {len(beijing_companies)} 家北京公司的类型更新为 'other'。")
else:
    print("\n没有找到符合条件的北京公司进行更新。")

4. 删除记录 (Delete) - unlink()

unlink() 方法用于从数据库中删除记录。它也作用于一个记录集上。

  • 参数:无。
  • 返回True (如果操作成功)。
  • 注意:这是一个破坏性操作,记录一旦删除,通常无法恢复(除非有数据库备份)。Odoo有权限控制,不是所有用户都能删除所有记录。
# 示例:删除之前创建的 new_partner_record
# 首先,最好重新获取一下记录,确保它仍然存在
partner_to_delete = Partner.browse(new_partner_record.id)

if partner_to_delete.exists():
    partner_name_before_delete = partner_to_delete.name
    partner_id_before_delete = partner_to_delete.id
    
    partner_to_delete.unlink()
    
    print(f"\n记录ID {partner_id_before_delete} (名称: {partner_name_before_delete}) 已被删除。")

    # 验证删除:尝试再次浏览该ID
    deleted_partner_check = Partner.browse(partner_id_before_delete)
    if not deleted_partner_check.exists():
        print(f"验证成功:ID {partner_id_before_delete} 的记录已不存在。")
    else:
        print(f"警告:ID {partner_id_before_delete} 的记录似乎仍存在,删除可能未完全成功或因权限/规则被阻止。")

else:
    print(f"\n要删除的记录 (原ID: {new_partner_record.id}) 已不存在或无权访问。")


# 批量删除:删除所有备注中包含 "临时" 字样的个人联系人
temporary_individuals = Partner.search([
    ('is_company', '=', False),
    ('comment', 'ilike', '%临时%')
])

if temporary_individuals:
    count_to_delete = len(temporary_individuals)
    # 在实际操作中,执行删除前最好有确认步骤或日志记录
    # for rec in temporary_individuals:
    #     print(f"准备删除: ID {rec.id}, 名称 {rec.name}")
    temporary_individuals.unlink()
    print(f"\n已删除 {count_to_delete} 个备注含'临时'的个人联系人。")
else:
    print("\n没有找到备注含'临时'的个人联系人进行删除。")

重要提示: unlink() 会触发关联记录的删除逻辑(如 ondelete 约束)。如果存在保护性约束(例如,一个订单关联到一个不能被删除的客户),删除操作可能会失败并抛出异常。


四、总结

Odoo ORM 是一个强大而便捷的工具,它极大地简化了数据库操作,让开发者能够更专注于业务逻辑的实现。通过本指南,你应该对Odoo ORM的核心概念有了初步的理解,并掌握了最基本的CRUD操作:

  • ORM 的本质:Python对象与数据库表之间的桥梁。
  • 核心组件:模型 (Model)、记录集 (Recordset)、环境 (env)。
  • 入口点self.env['model.name'] 是与模型交互的起点。
  • 优势:提高代码可读性、数据库无关性、增强安全性、提升开发效率。
  • CRUD 操作
    • YourModel.create(vals): 创建新记录。
    • YourModel.search(domain, limit, order): 根据条件查找记录。
    • YourModel.browse(ids): 根据ID获取记录。
    • recordset.write(vals): 更新记录集中的记录。
    • recordset.unlink(): 删除记录集中的记录。

这仅仅是Odoo ORM功能的冰山一角。随着你学习的深入,还会接触到更多高级特性,如:

  • 复杂的Domain条件组合
  • 记录集的其他操作方法(filtered(), mapped(), sorted() 等)
  • 计算字段 (Computed Fields) 和关联字段 (Related Fields)
  • onchange 方法
  • 继承 (_inherit, _inherits)
  • 约束 (_sql_constraints, _python_constraints)
  • 访问权限 (Access Rights)、记录规则 (Record Rules)

不断实践这些基础操作,并尝试在实际的小项目中应用它们,是巩固知识的最佳方式。祝你在Odoo开发的学习旅程中一切顺利!🚀


二、模型 (Model) 与字段 (Field) 类型

Odoo的强大之处在于其灵活且高度可定制的数据模型。理解Odoo的模型类型和各种字段类型是进行有效Odoo开发和定制的基础。本节旨在为Odoo开发者提供一份关于模型和字段的全面技术参考,覆盖从基础概念到高级应用的方方面面。


一、Odoo 模型类型 (Model Types)

在Odoo中,模型是业务对象的蓝图,定义了其数据结构和行为。模型通过继承Python类来实现。Odoo提供了几种不同类型的基类,用于不同场景:

1. models.Model (标准模型)

  • 描述:这是最常用的模型类型,用于创建持久化数据对象,这些对象的数据会存储在数据库的对应表中。每个标准模型通常都会在数据库中创建一个新表(除非_auto = False)。
  • 适用场景
    • 定义核心业务对象,如销售订单 (sale.order)、产品 (product.product)、客户 (res.partner) 等。
    • 任何需要长期存储和管理的数据。
  • 数据库交互:完全的CRUD(创建、读取、更新、删除)操作支持,并与Odoo的ORM紧密集成。
  • 示例
from odoo import models, fields

class LibraryBook(models.Model):
    _name = 'library.book' # 模型的技术名称,数据库表名通常是 library_book (下划线替换点)
    _description = 'Library Book' # 用户友好的描述

    name = fields.Char(string='Title', required=True)
    active = fields.Boolean(default=True)
    # ... 其他字段

2. models.TransientModel (临时模型 / 向导模型)

  • 描述:临时模型(也常被称为向导模型)用于存储临时数据,通常用于用户交互式的多步骤操作(向导)。它们的数据记录会定期从数据库中清除(通过cron任务)。
  • 适用场景
    • 创建复杂的向导界面,例如批量更新操作、报表参数输入、导入/导出配置等。
    • 在不污染主数据表的情况下收集用户输入。
  • 数据库交互:记录会存储在数据库中,但被视为临时的。它们有ID,但通常生命周期较短。
  • 示例
from odoo import models, fields, api

class AccountReportGeneralLedger(models.TransientModel):
    _name = 'account.report.general.ledger'
    _description = 'General Ledger Report Options'

    date_from = fields.Date(string='Start Date')
    date_to = fields.Date(string='End Date')
    target_move = fields.Selection([('posted', 'All Posted Entries'),
                                    ('all', 'All Entries')],
                                   string='Target Moves', required=True, default='posted')

    def _print_report(self, data):
        # 逻辑来生成报表
        return self.env.ref('account.action_report_general_ledger').report_action(self, data=data)

3. models.AbstractModel (抽象模型)

  • 描述:抽象模型不直接在数据库中创建表。它们作为可复用的功能集(mixins),可以被其他 models.Modelmodels.TransientModel 继承,以添加共享的字段和/或方法。
  • 适用场景
    • 定义一组通用的字段和方法,供多个模型共享,例如 mail.thread (用于消息、活动和关注者功能)、portal.mixin (用于门户访问)等。
    • 实现可插拔的功能模块。
  • 数据库交互:无直接的数据库表。其字段和方法在继承它的具体模型中生效。
  • 示例
from odoo import models, fields

class ArchiveMixin(models.AbstractModel):
    _name = 'archive.mixin'
    _description = 'Archive Mixin for active field'

    active = fields.Boolean(string="Active", default=True,
                            help="If unchecked, it will allow you to hide the record without removing it.")

    def action_archive(self):
        self.write({'active': False})

    def action_unarchive(self):
        self.write({'active': True})

class MyCustomModel(models.Model):
    _name = 'my.custom.model'
    _inherit = ['archive.mixin'] # 继承抽象模型

    name = fields.Char(string='Name')
    # MyCustomModel 现在自动拥有 active 字段和 action_archive/action_unarchive 方法

二、基础字段类型 (Basic Field Types)

Odoo提供了一系列基础字段类型来定义模型的数据结构。

字段类型

描述

常用属性 (string, required, readonly, index, default, help 通常都适用)

Char

短文本字符串

size (最大长度), trim (是否自动去除首尾空格, 默认True)

Text

长文本字符串 (无长度限制)

translate (是否可翻译)

Html

HTML格式的内容,通常用于富文本编辑器

sanitize (是否清理HTML, 默认True), strip_style (移除样式)

Integer

整数

Float

浮点数

digits (精度和小数位数, 如 (16, 2)'Product Price')

Boolean

布尔值 (True/False)

Date

日期 (年-月-日)

Datetime

日期时间 (年-月-日 时:分:秒,存储为UTC)

Selection

预定义选项列表

selection (选项列表), selection_add (动态扩展选项)

Binary

二进制数据 (如文件、图片)

attachment (True时,使用ir.attachment存储,否则直接存DB)

Monetary

货币值 (通常与一个Many2one货币字段关联)

currency_field (关联的货币字段名)

示例:

from odoo import models, fields, api
from odoo.tools.translate import _ # 用于翻译

class ProductTemplate(models.Model):
    _name = 'product.template'
    _description = 'Product Template'

    name = fields.Char(string='Product Name', required=True, translate=True, index=True)
    description_sale = fields.Text(string='Sale Description', translate=True, help="A description of the product for sales purposes.")
    notes_internal = fields.Html(string='Internal Notes', sanitize=False) # 允许更多HTML标签

    quantity_on_hand = fields.Integer(string='Quantity On Hand', default=0)
    standard_price = fields.Float(string='Cost', digits='Product Price', help="Cost price of the product.") # 使用预定义精度
    list_price = fields.Float(string='Sales Price', digits=(16, 2)) # 自定义精度

    active = fields.Boolean(string='Active', default=True)
    available_from = fields.Date(string='Available From')
    last_updated = fields.Datetime(string='Last Updated', readonly=True, default=fields.Datetime.now)

    product_type = fields.Selection([
        ('consu', 'Consumable'),
        ('service', 'Service'),
        ('product', 'Storable Product')
    ], string='Product Type', default='product', required=True)

    image_1920 = fields.Binary(string="Image", attachment=True) # 存储为附件
    
    # 假设有一个 currency_id 字段
    # company_currency = fields.Many2one('res.currency', related='company_id.currency_id')
    # sales_value = fields.Monetary(string="Sales Value", currency_field='company_currency')


    # Selection 示例 - 动态添加
    # state = fields.Selection(selection_add=[('custom_state', 'Custom State')])
    # @api.model
    # def _get_custom_states(self):
    #     # 逻辑来动态获取states
    #     return [('dynamic_state_1', 'Dynamic State 1')]
    #
    # state_dynamic = fields.Selection(selection=lambda self: self._get_custom_states(), string="Dynamic State")

三、关系型字段 (Relational Fields)

关系型字段用于在不同模型之间建立关联。

1. Many2one (多对一 / 外键)

  • 工作原理与用途
    • 在当前模型中创建一个字段,该字段存储目标模型(comodel_name)中一条记录的ID。
    • 表示当前模型的多条记录可以关联到目标模型的一条记录。例如,多个销售订单(sale.order)可以关联到同一个客户(res.partner)。
    • 数据库层面,这通常表现为当前模型表中的一个外键列。
    • 图示概念:
Model A (e.g., sale.order)          Model B (e.g., res.partner)
+-----------------+                   +-----------------+
| id (PK)         |                   | id (PK)         |
| name            |                   | name            |
| partner_id (FK) |------------------>| ...             |
| ...             |                   +-----------------+
+-----------------+
(Many 'sale.order' records can point to One 'res.partner' record)
  • 关键参数
    • comodel_name (str): 目标模型的名称 (例如 'res.partner')。必需。
    • string (str): 字段的显示标签。
    • ondelete (str): 定义当关联的目标记录被删除时的行为:
      • 'cascade': 当目标记录删除时,同时删除引用它的当前记录。
      • 'set null': 当目标记录删除时,将当前记录的此字段值设为 NULL (要求字段非required)。
      • 'restrict': 阻止删除被引用的目标记录 (如果仍有记录引用它,会抛出错误)。
      • 'set default': 当目标记录删除时,将当前记录的此字段值设为其默认值。
    • domain (list/str): 一个Odoo域表达式,用于在选择关联记录时过滤可选记录。
    • context (dict): 传递给视图或操作的上下文信息。
    • auto_join (bool): (性能优化) 如果为True,在搜索此字段时,ORM会尝试使用SQL的JOIN操作,通常可以提高性能,但某些情况下可能导致意外结果。默认为False
    • required (bool): 是否必填。
    • readonly (bool): 是否只读。
    • index (bool): 是否为此字段创建数据库索引。
  • 代码示例
class SaleOrder(models.Model):
    _name = 'sale.order'
    _description = 'Sales Order'

    partner_id = fields.Many2one(
        comodel_name='res.partner',  # 目标模型是 res.partner
        string='Customer',
        required=True,
        ondelete='restrict',      # 如果客户被删除,且有订单关联,则阻止删除客户
        index=True,
        domain="[('is_company', '=', True)]" # 只允许选择公司类型的伙伴
    )
    user_id = fields.Many2one(
        'res.users',
        string='Salesperson',
        default=lambda self: self.env.user, # 默认当前用户
        ondelete='set null' # 如果销售员用户被删除,订单中的销售员字段置空
    )

2. One2many (一对多)

  • 工作原理与用途
    • 表示当前模型的一条记录可以关联到目标模型(comodel_name)的多条记录。
    • One2many 字段本身不会在当前模型的数据库表中创建列。它是一个“虚拟”字段,其值是通过反向查找目标模型中对应的 Many2one 字段来动态计算的。
    • 例如,一个客户 (res.partner) 可以有多张销售订单 (sale.order)。在 res.partner 模型中会有一个 One2many 字段指向 sale.order
    • 图示概念:
Model B (e.g., res.partner)       Model A (e.g., sale.order)
+-----------------+                   +-----------------+
| id (PK)         |                   | id (PK)         |
| name            |                   | name            |
| sale_order_ids  |<------------------| partner_id (FK) |
| (One2many)      |                   | ...             |
+-----------------+                   +-----------------+
(One 'res.partner' record can have Many 'sale.order' records linked via 'sale_order.partner_id')
  • 关键参数
    • comodel_name (str): 目标模型的名称 (例如 'sale.order')。必需。
    • inverse_name (str): 目标模型中对应的那个 Many2one 字段的名称 (例如 'partner_id')。必需。这是建立双向连接的关键。
    • string (str): 字段的显示标签。
    • domain (list/str): 过滤显示的关联记录。
    • context (dict): 传递给视图或操作的上下文信息。
    • limit (int): 限制显示的记录数量。
    • auto_join (bool): 通常不适用于 One2many 字段,因为它不直接对应数据库列。
  • 代码示例
# 在 res.partner 模型中
class ResPartner(models.Model):
    _inherit = 'res.partner' # 假设我们扩展已有的 res.partner

    # sale_order_ids 是一个 One2many 字段
    # 它关联到 'sale.order' 模型
    # 通过 'sale.order' 模型中的 'partner_id' (Many2one) 字段反向查找
    sale_order_ids = fields.One2many(
        comodel_name='sale.order',      # 目标模型
        inverse_name='partner_id',      # 目标模型中指向当前模型的Many2one字段名
        string='Sales Orders'
    )
    # 我们还需要在 sale.order 模型中定义 partner_id (如上一个Many2one示例所示)

3. Many2many (多对多)

  • 工作原理与用途
    • 表示当前模型的记录可以与目标模型的多个记录相关联,反之亦然。
    • 例如,一个产品(product.product)可以属于多个类别(product.category),一个类别也可以包含多个产品。
    • Odoo会自动(或允许你指定)一个中间关联表 (relation table) 来存储这种多对多关系。这个表至少包含两列,分别作为外键指向参与关联的两个模型的主键。
    • 图示概念:
Model A (e.g., product.product)      Relation Table (e.g., product_category_rel)      Model B (e.g., product.category)
+-----------------+                    +------------------------------------+                    +-----------------+
| id (PK)         |                    | product_id (FK to Model A.id)  |                    | id (PK)         |
| name            |--------------------| category_id (FK to Model B.id) |--------------------| name            |
| ...             |                    +------------------------------------+                    | ...             |
+-----------------+                                                                         +-----------------+
(A product can have many categories, and a category can have many products, via the relation table)
  • 关键参数
    • comodel_name (str): 目标模型的名称。必需。
    • relation (str, optional): 中间关联表的名称。如果未提供,Odoo会根据两个模型的名称自动生成 (例如 model1_model2_rel)。
    • column1 (str, optional): 中间关联表中,指向当前模型记录ID的外键列名。如果未提供,Odoo会自动生成 (例如 model1_id)。
    • column2 (str, optional): 中间关联表中,指向目标模型记录ID的外键列名。如果未提供,Odoo会自动生成 (例如 model2_id)。
    • string (str): 字段的显示标签。
    • domain (list/str): 过滤可选的关联记录。
    • context (dict): 传递给视图或操作的上下文。
  • 代码示例
# 假设我们有 'library.book' 和 'library.tag' 模型
class LibraryBook(models.Model):
    _name = 'library.book'
    # ... (name, etc.)

    # 自动创建关联表 'library_book_library_tag_rel'
    # 自动创建列 'library_book_id' 和 'library_tag_id'
    tag_ids = fields.Many2many(
        comodel_name='library.tag',
        string='Tags'
    )

class LibraryTag(models.Model):
    _name = 'library.tag'
    _description = 'Library Tag'
    name = fields.Char(required=True)

    # 如果需要,也可以在这里定义反向的 Many2many
    # book_ids = fields.Many2many(
    #     comodel_name='library.book',
    #     string='Books'
    # ) # Odoo会自动使用相同的关联表


# 示例:显式定义关联表和列名
class Course(models.Model):
    _name = 'my.course'
    _description = 'Course'
    name = fields.Char(required=True)

    student_ids = fields.Many2many(
        comodel_name='res.partner',
        relation='course_student_enrollment_rel', # 自定义关联表名
        column1='course_id',                      # 指向本模型 (my.course) 的列
        column2='student_id',                     # 指向目标模型 (res.partner) 的列
        string='Enrolled Students',
        domain="[('is_company', '=', False)]" # 只允许选择个人类型的伙伴
    )

操作Many2many字段:写入Many2many字段时,通常使用特殊的"命令"格式,例如:

    • (0, 0, {'field_name': value, ...}):创建并链接一条新的comodel_name记录。
    • (1, ID, {'field_name': value, ...}):更新ID对应的已链接记录。
    • (2, ID):取消链接并删除ID对应的comodel_name记录 (如果该记录仅被此Many2many链接)。
    • (3, ID):取消链接ID对应的记录 (不删除)。
    • (4, ID):链接一个已存在的ID对应的记录。
    • (5):取消链接所有记录 (不删除)。
    • (6, 0, [IDs]):用IDs列表中的记录替换所有已链接的记录。

四、特殊功能字段 (Specialized Fields)

1. 计算字段 (Computed Fields)

  • 用途与定义:字段的值不是直接从数据库读取或由用户输入,而是通过一个Python方法动态计算得出。
  • 关键参数
    • compute (str): 计算该字段值的Python方法的名称(字符串形式)。
    • @api.depends('field1', 'field2', ...): 一个装饰器,用于声明该计算字段依赖于哪些其他字段。当依赖字段的值发生变化时,此计算字段会自动重新计算。
    • store (bool):
      • False (默认): 字段值不存储在数据库中,每次访问时动态计算。不能直接搜索或分组。
      • True: 字段值存储在数据库中。当依赖项变化时重新计算并存储。存储的计算字段可以被搜索和分组(如果其类型支持)。
    • search (str, optional): 如果store=False,但仍希望能够按此字段搜索,可以提供一个实现自定义搜索逻辑的方法名。
    • inverse (str, optional): 提供一个方法名,用于实现反向逻辑,使得计算字段可以被"写入"。当用户尝试修改计算字段时,会调用此inverse方法,开发者可以在其中更新实际依赖的字段。
    • readonly (bool): 通常计算字段默认为只读,除非定义了 inverse 方法。
  • 完整代码示例
from odoo import models, fields, api

class SaleOrderLine(models.Model):
    _name = 'sale.order.line'
    _description = 'Sales Order Line'

    product_id = fields.Many2one('product.product', string='Product')
    product_uom_qty = fields.Float(string='Quantity', default=1.0)
    price_unit = fields.Float(string='Unit Price')
    tax_ids = fields.Many2many('account.tax', string='Taxes')
    
    # 计算字段:小计金额
    price_subtotal = fields.Monetary(
        string='Subtotal',
        compute='_compute_price_subtotal',
        store=True, # 将计算结果存储到数据库
        currency_field='currency_id' # 假设存在 currency_id 字段
    )
    currency_id = fields.Many2one(related='order_id.currency_id', store=True) # 假设 order_id 存在

    # 假设 order_id 字段存在,并且指向 sale.order
    order_id = fields.Many2one('sale.order', string='Order Reference', ondelete='cascade')


    @api.depends('product_uom_qty', 'price_unit', 'tax_ids')
    def _compute_price_subtotal(self):
        for line in self:
            # 简化计算,实际应考虑税费
            # Odoo 有 account.tax 的 compute_all 方法来计算税后金额
            price = line.price_unit * (1 - 0.0) # 假设无折扣
            taxes = line.tax_ids.compute_all(price, line.currency_id, line.product_uom_qty, product=line.product_id, partner=line.order_id.partner_id)
            line.price_subtotal = taxes['total_included'] if taxes else line.product_uom_qty * line.price_unit
            
    # 示例:可写可搜索的计算字段 (更复杂)
    # full_name = fields.Char(string="Full Name", compute='_compute_full_name', inverse='_inverse_full_name', store=True, search='_search_full_name')
    # first_name = fields.Char()
    # last_name = fields.Char()

    # @api.depends('first_name', 'last_name')
    # def _compute_full_name(self):
    #     for record in self:
    #         record.full_name = f"{record.first_name or ''} {record.last_name or ''}".strip()

    # def _inverse_full_name(self):
    #     for record in self:
    #         parts = (record.full_name or '').split(' ', 1)
    #         record.first_name = parts[0]
    #         record.last_name = parts[1] if len(parts) > 1 else ''
    
    # @api.model
    # def _search_full_name(self, operator, value):
    #     # 实现自定义搜索逻辑,例如搜索 first_name 或 last_name
    #     if operator == 'ilike':
    #         domain = ['|', ('first_name', operator, value), ('last_name', operator, value)]
    #         return domain
    #     return [('id', '=', 0)] # Fallback for unhandled operators

2. 关联字段 (Related Fields)

  • 用途与定义:字段的值是另一个模型的字段值的“快捷方式”或“引用”。它通过一串关系字段(通常是Many2one字段)链来获取值。
  • 关键参数
    • related (str): 一个点分隔的字段名路径,用于从当前模型通过关系字段到达目标字段。例如 'partner_id.country_id.name'
    • store (bool): 通常设置为 True 以将值存储在数据库中,这样可以被搜索、排序和分组。如果为 False,则行为类似非存储的计算字段。
    • readonly (bool): 关联字段通常是只读的(readonly=True 是默认行为,除非显式设置为 False 并指定 inverse 逻辑,但这不常见,通常用计算字段的 inverse)。
    • 类型必须与路径末端的目标字段类型匹配。
  • 代码示例
class SaleOrderLine(models.Model):
    _inherit = 'sale.order.line' # 扩展已有的模型

    # 假设 order_id 字段 (Many2one to 'sale.order') 已经存在
    # order_id = fields.Many2one('sale.order', string='Order Reference')

    # 关联字段:获取订单客户的名称
    order_partner_name = fields.Char(
        related='order_id.partner_id.name', # 路径:sale.order.line -> sale.order -> res.partner -> name
        string='Order Customer Name',
        store=True, # 存储值,使其可搜索
        readonly=True
    )

    # 关联字段:获取产品的销售描述
    product_sale_description = fields.Text(
        related='product_id.description_sale',
        string='Product Sale Description',
        store=False # 非存储,每次动态获取 (如果 product_id.description_sale 变化频繁且不需要搜索此字段)
    )

3. 公司依赖字段 (Company Dependent Fields)

  • 用途与定义:允许字段为系统中的每个公司存储不同的值,即使是同一条记录。这对于多公司环境下的配置项或特定于公司的数据非常有用。
  • 关键参数
    • company_dependent (bool): 设置为 True 来启用此行为。
    • store (bool): 如果 company_dependent=True,Odoo通常会将这些值存储在 ir.property 模型中,并根据当前用户所在公司动态获取。如果也设置了store=True,Odoo 13.0之后会尝试在主表为每个公司创建列(如果字段是简单类型),但这更复杂,依赖于 _group_by_company() 方法的实现。通常 ir.property 是主要机制。
  • 代码示例
class ProductTemplate(models.Model):
    _inherit = 'product.template'

    # 例如,每个公司的默认销售税可能不同
    # 注意:实际的 account.tax 字段本身不是 company_dependent,
    # 而是通过 ir.property 来设置其默认值。
    # 一个更好的例子可能是某个特定于产品的公司级配置
    
    company_specific_info = fields.Char(
        string="Company Specific Info",
        company_dependent=True,
        help="This information can vary per company for the same product template."
    )
    
    # 对于某些字段如 account.account 的 account_id 字段,
    # Odoo 会使用 ir.property 机制使其公司依赖
    # property_account_expense_categ_id = fields.Many2one('account.account',
    #     company_dependent=True, string="Expense Account",
    #     domain="[('internal_type', '=', 'other'), ('deprecated', '=', False)]",
    #     help="Keep this field empty to use the default value from the product category.")

# 访问 company_dependent 字段时,Odoo ORM 会自动考虑当前上下文中的公司。
# product = self.env['product.template'].browse(some_id)
# info_for_current_company = product.company_specific_info

# product_for_company_x = product.with_company(company_x_id)
# info_for_company_x = product_for_company_x.company_specific_info

4. 计算、关联与公司依赖字段的对比

特性

计算字段 (compute)

关联字段 (related)

公司依赖字段 (company_dependent)

数据来源

Python 方法计算

通过关系路径从其他模型字段获取

根据当前公司上下文从ir.property或特定机制获取

存储

可选 (store=True/False)

通常建议存储 (store=True)

通常由ir.property管理,或特殊列(较少)

只读性

默认只读,可通过 inverse 方法变可写

默认只读(除非显式readonly=False并处理)

可写,值特定于公司

依赖

通过 @api.depends 显式声明

隐式依赖于路径上的所有字段

依赖于当前公司上下文

主要用途

动态值、聚合、复杂逻辑

简化对深层嵌套数据的访问

多公司环境下,同一记录不同公司有不同值


五、常用字段属性总结 (Common Field Attributes)

下表总结了定义字段时最常用的一些属性:

属性名

Python 类型

描述

示例值/说明

string

str

字段在用户界面上的显示标签。

'Customer Name'

required

bool

如果为 True,则该字段在保存记录时必须有值。

True / False (默认)

readonly

bool

如果为 True,则该字段在用户界面上通常不可编辑(除非特定条件)。

True / False (默认)

index

bool

如果为 True,则在数据库中为此字段创建索引,以加速搜索和排序。

True / False (默认)

default

any

字段的默认值。可以是直接值,也可以是一个返回值的函数 (lambda self: ...)。

0, 'draft', fields.Date.today, lambda s: s.env.user

help

str

鼠标悬停在字段上时显示的帮助文本/提示。

'Enter the full legal name of the customer.'

copy

bool

当记录被复制(record.copy())时,此字段的值是否也应被复制。默认True

False (例如,唯一编号不应被复制)

groups

str

CSV格式的XML ID列表,指定哪些用户组可以看到此字段。

'base.group_user,sales_team.group_sale_manager'

deprecated

bool

标记字段为已弃用。Odoo可能在日志中给出警告。

True

translate

bool

(Char, Text, Html) 字段值是否可翻译。

True / False (默认)

trim

bool

(Char) 是否自动去除首尾空格,默认True

False

size

int

(Char) 字段的最大长度限制。

128

digits

tuple/str

(Float) 定义浮点数的总位数和小数位数,或引用预定义的精度。

(10, 3), 'Product Price'

selection

list/func

(Selection) 定义选项。列表元素是(value, label)元组,或返回此列表的函数。

[('a','A'), ('b','B')]

selection_add

list

(Selection) 动态地向通过继承获得的selection字段添加选项。

[('c','New C')],通常在_inherit时使用

comodel_name

str

(Many2one, One2many, Many2many) 目标模型的名称。

'res.partner'

inverse_name

str

(One2many) 目标模型中对应的Many2one字段名。

'order_id'

ondelete

str

(Many2one) 关联记录被删除时的策略。

'cascade', 'set null', 'restrict'

domain

list/str

(Many2one, One2many, Many2many) 过滤关联记录的选择范围。

[('is_company', '=', True)]

context

dict

(Many2one, One2many, Many2many) 传递给视图或操作的上下文。

{'default_type': 'out_invoice'}

compute

str

(all types) 计算字段值的Python方法名。

'_compute_total_amount'

related

str

(all types) 关联字段的路径。

'partner_id.country_id.name'

store

bool

(compute, related) 是否将字段值存储到数据库。

True / False

company_dependent

bool

(most simple types, Many2one) 字段值是否依赖于公司。

True

currency_field

str

(Monetary) 关联的货币字段名称 (必须是Many2one到res.currency)。

'currency_id'

attachment

bool

(Binary) True时,使用ir.attachment存储,否则直接存DB(大对象)。

True (推荐)


六、结语

深入理解Odoo的模型和字段类型是成为一名高效Odoo开发者的基石。本参考文档涵盖了从模型基础到各种字段类型及其属性的详细说明,希望能为您的Odoo开发工作提供有力的支持。

请记住,Odoo的文档和源代码是学习和解决特定问题的最佳资源。随着Odoo版本的迭代,某些细节可能会发生变化,因此请始终参考您正在使用的Odoo版本的官方文档。


三、记录集 (Recordset) 操作

在Odoo开发中,几乎所有的数据库交互都是通过其强大的ORM层完成的,而记录集(Recordset) 正是ORM的核心。无论你是处理单条客户信息,还是成千上万的订单数据,你都在与记录集打交道。因此,深入理解记录集并掌握其高效操作方法,对于编写高性能、可维护的Odoo应用至关重要。


一、什么是记录集 (Recordset)?

简单来说,Odoo中的记录集是一个Python对象,它代表了来自特定模型(Model)的一组有序记录。这组记录可以是:

  • 空记录集:不包含任何记录。
  • 单条记录:只包含一条记录。
  • 多条记录:包含模型中的多条记录。

重要性

  • 统一接口:无论是单条还是多条记录,你都使用相同的方法进行操作,简化了代码逻辑。
  • ORM交互核心:所有ORM操作(如创建、读取、更新、删除)几乎都围绕记录集进行。
  • 数据载体:记录集持有从数据库中获取的数据,并在Python层进行处理。

当你执行 self.env['your.model'].search([...])self.env['your.model'].browse(ids) 时,返回的就是一个记录集。即使只 browse() 一条记录,它依然是一个包含单个元素的记录集。

# 获取 res.partner 模型的记录集入口
Partner = self.env['res.partner']

# 搜索所有公司,返回一个可能包含多条记录的记录集
companies = Partner.search([('is_company', '=', True)])
print(f"Found {len(companies)} companies.") # len() 返回记录集中的记录数量

# 浏览单个ID,返回一个包含单条记录的记录集 (如果ID存在)
a_partner = Partner.browse(1)
if a_partner.exists(): # 检查记录是否存在
    print(f"Partner with ID 1: {a_partner.name}")

# 空记录集
no_one = Partner.browse([]) # 或者 Partner.search([('id', '=', -1)])
print(f"Is 'no_one' empty? {not no_one}") # True

二、记录集的基本操作

记录集在行为上与Python列表有相似之处,支持一些基本操作:

1. 迭代 (Iteration)

你可以像遍历列表一样遍历记录集中的每条记录。

all_partners = self.env['res.partner'].search([], limit=5)
for partner in all_partners:
    # partner 在这里是代表单条伙伴记录的记录集
    print(f"Partner ID: {partner.id}, Name: {partner.name}")

⚠️ 性能注意:迭代操作本身在Python层面执行。如果记录集非常大(例如,数万条记录),并且在循环内部进行复杂处理或额外的数据库查询(N+1问题),性能会急剧下降。

2. 索引与切片 (Indexing & Slicing)

  • 索引recordset[index] 返回记录集中指定位置的单条记录(仍然是一个记录集)。
  • 切片recordset[start:stop] 返回一个新的记录集,它是原子记录集的子集。
partners = self.env['res.partner'].search([], limit=10, order='name')

if len(partners) >= 3:
    first_partner = partners[0] # 获取第一条记录
    print(f"First partner: {first_partner.name}")

    # 获取前三个伙伴
    first_three_partners = partners[:3]
    print(f"First three partners: {[p.name for p in first_three_partners]}")

    # 获取从第二个到第四个伙伴 (不包括第四个)
    some_partners = partners[1:4]
    print(f"Some partners: {[p.name for p in some_partners]}")

⚠️ 性能注意:这些操作在Python层面进行,操作的是已经从数据库加载到内存中的记录集(或其ID列表)。


三、核心记录集操作方法详解 (与性能考量)

掌握以下方法是高效处理数据的关键。核心区别在于操作是在数据库层面还是在Python内存层面执行。

1. search(domain, limit=None, order=None, offset=0)

  • 功能与用途:根据指定的查询条件(domain)从数据库中检索记录。
  • 执行层面数据库 (SQL WHERE 子句)。这是最主要的从数据库过滤数据的方式。
  • 代码示例
Partner = self.env['res.partner']
# 查找所有来自中国的公司客户,并按名称排序
chinese_companies = Partner.search([
    ('is_company', '=', True),
    ('country_id.code', '=', 'CN') # 跨表查询国家代码
], order='name', limit=100)
  • 🚀 性能考量与最佳实践
    • 首选过滤方式:尽可能使用 domain 来精确过滤数据,只从数据库获取你真正需要的记录。这能显著减少传输到Python层的数据量和后续处理时间。
    • 索引:确保 domain 中用到的字段(特别是频繁查询的字段)在数据库中有索引。
    • limitoffset:用于分页,避免一次性加载过多数据。

2. search_count(domain)

  • 功能与用途:高效地获取满足查询条件(domain)的记录数量。
  • 执行层面数据库 (SQL COUNT(*) )
  • 代码示例
Partner = self.env['res.partner']
active_customer_count = Partner.search_count([
    ('active', '=', True),
    ('customer_rank', '>', 0)
])
print(f"Number of active customers: {active_customer_count}")
  • 🚀 性能考量与最佳实践
    • 极高效:当你只需要数量而不需要记录本身时,search_count()len(YourModel.search(domain)) 快得多,因为它避免了将实际记录数据从数据库传输到Python应用。

3. browse(ids)

  • 功能与用途:通过一个或多个记录ID直接从数据库中获取记录。
  • 执行层面数据库 (SQL WHERE id IN (...))
  • 代码示例
Partner = self.env['res.partner']
partner_ids = [1, 5, 10] # 假设这些ID存在
known_partners = Partner.browse(partner_ids)

# 也可以传入单个ID
a_partner = Partner.browse(1)
  • 🚀 性能考量与最佳实践
    • 高效获取已知ID:如果你已经知道记录的ID,这是获取它们的最直接方式。
    • 缓存友好:Odoo的ORM有缓存机制,browse() 操作通常能很好地利用缓存,对于已加载的记录会非常快。
    • 避免 browse(False)browse(None),这会返回空记录集,但如果ID来源不确定,最好先检查。

4. filtered(func_or_string)

  • 功能与用途:在已获取的记录集上,根据Python函数或简单的点号表达式字符串进行过滤。
  • 执行层面Python 内存
  • 代码示例
# 假设 partners 是一个已经存在的记录集
# partners = self.env['res.partner'].search([('category_id', '!=', False)])

# 使用 lambda 函数过滤:找出名称中包含 "Inc" 的伙伴
inc_partners = partners.filtered(lambda p: "Inc" in p.name)

# 使用点号表达式字符串过滤:找出活跃的伙伴
# (前提是 partners 记录集已经包含了 active 字段的值)
active_partners_in_memory = partners.filtered(lambda p: p.active) # Python check
# 更简洁的点号表达式 (Odoo 优化,但仍是Python层)
active_partners_dot_notation = partners.filtered("active")
  • ⚠️ 性能考量与最佳实践
    • 适用场景
      1. 当过滤逻辑非常复杂,难以或无法用 search()domain 表达时。
      2. 当操作的记录集已经很小并且已加载到内存中时。
    • 性能陷阱避免在大记录集上使用 filtered() 进行本可以通过 search() 完成的过滤。

例如,self.env['res.partner'].search([]).filtered(lambda p: p.country_id.code == 'US')非常低效的。它会加载所有伙伴到内存,然后再在Python中过滤。正确做法是 self.env['res.partner'].search([('country_id.code', '=', 'US')])

5. mapped(field_name_or_func)

  • 功能与用途:从记录集中的每条记录提取特定字段的值或通过函数计算一个值,并返回一个列表(如果提取的是简单字段)或一个新的记录集(如果提取的是关系字段如Many2one, One2many, Many2many)。
  • 执行层面Python 内存(但对于简单的字段名提取,Odoo内部有优化,可能比手动循环更快)。
  • 代码示例
# partners 是一个记录集
# partners = self.env['res.partner'].search([('customer_rank', '>', 0)], limit=5)

# 提取所有伙伴的名称 (返回一个列表)
partner_names = partners.mapped('name')
# ['Partner A', 'Partner B', ...]

# 提取所有伙伴的国家记录 (返回一个 res.country 的记录集)
countries = partners.mapped('country_id')
# res.country(1, 2, 5, ...)  (包含去重后的国家记录)

# 使用 lambda 函数进行更复杂的操作 (返回列表)
# 获取每个伙伴名称及其ID的元组列表
name_id_list = partners.mapped(lambda p: (p.id, p.name))
# [(1, 'Partner A'), (2, 'Partner B'), ...]
  • 🚀 性能考量与最佳实践
    • 比手动循环更优:对于简单的字段提取 (mapped('field_name')),通常比 [record.field_name for record in recordset] 更高效,因为Odoo可能在C层进行了优化。
    • 注意N+1问题:如果 mapped() 一个未预先加载的关系字段(例如,在一个大记录集上 mapped('address_ids.city')),并且该字段没有被预取(prefetch),可能会触发大量的数据库查询。
    • mapped() 一个关系字段(如country_id)时,返回的是一个记录集,可以继续在其上进行操作。它会自动处理重复项,只返回唯一的关联记录。

6. sorted(key_func_or_string, reverse=False)

  • 功能与用途:在已获取的记录集上,根据Python函数或字段名字符串进行排序。
  • 执行层面Python 内存
  • 代码示例
# partners 是一个记录集
# partners = self.env['res.partner'].search([('city', '=', 'Brussels')])

# 按名称长度排序 (使用 lambda)
sorted_by_name_length = partners.sorted(key=lambda p: len(p.name))

# 按创建日期降序排序 (使用字段名字符串)
sorted_by_date_desc = partners.sorted(key='create_date', reverse=True)
  • ⚠️ 性能考量与最佳实践
    • 适用场景:与 filtered() 类似,适用于已加载到内存的小记录集,或者当排序逻辑无法通过 search()order 参数表达时。
    • 性能陷阱避免在大记录集上使用 sorted() 进行本可以通过 search(order=...) 完成的排序。 数据库排序通常远快于在Python中排序大量数据。

四、记录集的链式操作 (Chaining) 

Odoo记录集的一个强大特性是其操作方法通常返回记录集本身(或列表),这使得可以将多个操作链接起来,形成清晰、简洁的代码。

Partner = self.env['res.partner']

# 链式操作示例:
# 1. 搜索所有州的ID为10的客户 (DB)
# 2. 然后在内存中过滤出电话号码已设置的客户 (Python)
# 3. 接着提取这些客户的邮件地址 (Python)
# 4. 最后按邮件地址排序 (Python)
emails_of_ca_partners_with_phone = Partner.search([
    ('state_id', '=', 10) # 假设ID为10是加州
]).filtered(
    lambda p: p.phone
).mapped(
    'email'
).sorted() # .sorted() on a list of strings

print(emails_of_ca_partners_with_phone)

# 另一个例子:获取前5个公司客户,然后提取其国家记录集,再提取国家名称
top_company_country_names = Partner.search(
    [('is_company', '=', True)],
    limit=5,
    order='name'
).mapped(
    'country_id'  # 这会返回一个 res.country 的记录集 (去重)
).mapped(
    'name'        # 在国家记录集上提取名称,返回列表
)
print(top_company_country_names)

🚀 性能考量

链式操作的可读性很高,但要时刻注意每一步是在数据库层面还是Python层面执行。一个低效的Python层操作(如在大记录集上 filtered)会成为整个链条的性能瓶颈。优先将能在数据库层面完成的操作(如 search 的domain和order)放在链条的前面。


五、性能对比:数据库查询 vs Python 内存操作

这是性能优化的核心:

特性

数据库层面操作 (search, search_count, browse)

Python 内存操作 (filtered, mapped, sorted on fetched data)

数据源

直接操作数据库中的所有数据

操作已从数据库加载到Python内存中的数据子集

过滤/排序

通常使用SQL,由数据库引擎优化,非常高效

Python循环或内置函数,对于大数据量开销较大

数据传输

search/browse 只传输最终结果集 (或其ID)

依赖于前一步骤获取了多少数据到内存中

适用场景

大数据集的初始过滤、计数、排序、按ID获取

小数据集的复杂逻辑处理、已加载数据的再加工

主要优势

速度快、资源消耗低 (对Python应用而言)

灵活性高,可实现任意Python逻辑

主要风险

无(如果SQL本身写得好)

内存溢出、CPU高负载 (如果操作大数据集)

search() vs filtered() 关键对比:

  • self.env['res.partner'].search([('country_id.code', '=', 'US')])
    • 高效: 数据库直接返回美国客户。只传输必要数据。
  • self.env['res.partner'].search([]).filtered(lambda p: p.country_id and p.country_id.code == 'US')
    • 极低效: 加载所有客户到内存,然后在Python中逐个检查国家。数据传输量大,Python处理慢。

循环取值 vs mapped() 关键对比:

  • names = [p.name for p in partners] (Python循环)
  • names = partners.mapped('name')
    • 对于简单字段访问,mapped('name') 通常更快,因为它可能利用了Odoo的内部C语言优化。对于复杂的lambda表达式,mapped(lambda ...) 的性能与手写循环相似,但代码更简洁。

六、总结与性能优化黄金法则 

  1. 优先数据库过滤:尽可能使用 search()domainorder 参数在数据库层面完成数据筛选和排序。这是最重要的性能法则。
  2. 精确获取数量:当你只需要记录数量时,永远使用 search_count()
  3. 按需索取:只 search() 你真正需要的字段和记录。虽然Odoo有字段预取机制,但庞大的初始数据集总是坏事。
  4. 理解执行层面:清楚每个操作是在数据库执行还是在Python内存中执行,这是做出正确选择的前提。
  5. 小数据集上的Python操作filtered(), mapped(), sorted() 非常适合对已加载到内存中的小规模数据集进行复杂或灵活的处理。
  6. 警惕N+1查询:在循环或 mapped(lambda ...) 中访问未预取的关系字段,可能导致对每条记录都发起一次或多次数据库查询,严重影响性能。使用Odoo的 name_get(),预取(prefetch_fields参数或上下文prefetch_fields=True),或设计更优的 search domain来避免。
  7. 利用链式操作的可读性,但注意性能顺序:将DB操作前置。
  8. 测试与分析:对于性能敏感的操作,使用Odoo的日志(开启--log-sql)或性能分析工具(如 psycopg2.extensions.cursor.timestamp 或Odoo Server Action中的代码执行时间分析)来验证你的假设。

通过遵循这些原则,你将能够编写出更高效、更专业的Odoo代码,确保系统在高数据负载下依然流畅运行。祝你优化愉快!


四、搜索域 (Domain) 精解

在Odoo中,搜索域 (Domain) 是一种核心机制,用于根据特定条件从数据库中筛选记录。无论是在Python代码中执行 search()search_count() 方法,还是在XML视图(如搜索视图、字段的 domain 属性、菜单动作的 domain 属性等)中定义过滤规则,Domain都扮演着至关重要的角色。理解并熟练运用Domain语法是Odoo开发的关键技能。


一、搜索域 (Domain) 基础语法

Odoo的Domain本质上是一个列表 (List),列表中的每个元素定义了一个或多个条件。最基本的条件单元是一个包含三个元素的元组 (Tuple)

('field_name', 'operator', 'value')
  • 'field_name' (str): 需要进行比较的字段的技术名称。可以是当前模型的字段,也可以是通过点号.访问的关联模型的字段 (例如 'partner_id.country_id.code')。
  • 'operator' (str): 用于比较字段值和提供值的操作符。
  • 'value' (any): 用于与字段值进行比较的值。其类型取决于字段类型和操作符。

一个Domain是这些三元组条件的列表。默认情况下,列表中的多个条件之间是逻辑 "AND" 的关系。

# 示例:查找所有活跃的 (active=True) 并且是公司类型 (is_company=True) 的合作伙伴
domain = [
    ('active', '=', True),
    ('is_company', '=', True)
]
# 这个Domain等同于:active IS TRUE AND is_company IS TRUE

二、常用操作符详解

下表列出了Odoo Domain中常用的操作符及其说明:

操作符

描述

值类型示例

Python 示例

=

等于

True, 10, 'text', False

('state', '=', 'done'), ('partner_id', '=', partner_id)

!=

不等于

True, 10, 'text', False

('state', '!=', 'cancel')

>

大于

10, '2023-01-01'

('amount_total', '>', 1000)

<

小于

10, '2023-01-01'

('probability', '<', 0.5)

>=

大于等于

10, '2023-01-01'

('expected_revenue', '>=', 5000)

<=

小于等于

10, '2023-01-01'

('create_date', '<=', '2023-12-31 23:59:59')

=?

等于或未设置 (NULL)。通常用于 Many2one 字段,判断是否等于某个ID或未设置。

partner_id (int) 或 False

('user_id', '=?', self.env.user.id) (等于当前用户或未分配)

like

SQL LIKE 操作符,区分大小写 (具体行为可能依赖数据库配置)。使用 % 作为通配符。

'search_pattern%'

('name', 'like', 'Odoo%')

ilike

SQL ILIKE 操作符,不区分大小写。使用 % 作为通配符。

'search_pattern%'

('email', 'ilike', '%@example.com')

not like

SQL NOT LIKE 操作符,区分大小写。

'search_pattern%'

('reference', 'not like', 'OBSOLETE%')

not ilike

SQL NOT ILIKE 操作符,不区分大小写。

'search_pattern%'

('description', 'not ilike', '%temporary%')

=like

等于 value (行为类似 =, 但可能在内部使用 LIKE 实现)。不使用 % 通配符。

'exact_text'

('zip', '=like', '90210') (通常用 = 即可)

=ilike

等于 value (不区分大小写)。不使用 % 通配符。

'exact_text'

('vat', '=ilike', 'be0123456789') (通常用 ilike 加完整字符串)

in

字段值在给定的列表中。

[val1, val2, val3]

('state', 'in', ['draft', 'sent', 'sale']), ('id', 'in', list_of_ids)

not in

字段值不在给定的列表中。

[val1, val2, val3]

('product_type', 'not in', ['service'])

child_of

字段值是给定记录的子记录(或记录本身)。用于具有父子层级关系的字段 (parent_path 字段)。

parent_id (int) 或 [parent_ids]

('location_id', 'child_of', stock_warehouse_id)

parent_of

字段值是给定记录的父记录(或记录本身)。用于具有父子层级关系的字段。

child_id (int) 或 [child_ids]

('parent_id', 'parent_of', department_id)

(unaccent)

(Odoo 15+) 移除了 unaccent 扩展的显式操作符,ilike 通常会结合数据库的 unaccent 功能自动处理(如果已安装和配置)。

特别说明 like vs ilike:

  • like: 进行大小写敏感的模式匹配。例如, ('name', 'like', 'Apple') 只会匹配 "Apple",不会匹配 "apple"。
  • ilike: 进行大小写不敏感的模式匹配。例如, ('name', 'ilike', 'apple') 会匹配 "Apple", "apple", "APPLE" 等。
  • 通配符 %:代表零个、一个或多个任意字符。
    • 'Apple%': 匹配以 "Apple" 开头的字符串。
    • '%Inc.': 匹配以 "Inc." 结尾的字符串。
    • '%Solution%': 匹配任何包含 "Solution" 的字符串。
  • 通配符 _: 代表单个任意字符。 (较少使用,% 更常见)

三、逻辑操作符:构建复杂条件

当需要组合多个条件时,可以使用逻辑操作符。Odoo Domain使用前缀表示法 (Polish Notation)

  1. '&' (AND):
    • 虽然多个连续的条件元组默认是 AND 关系,但 & 可以用来显式组合。在更复杂的嵌套中,它有助于明确逻辑。
    • ['&', condition1, condition2] 表示 condition1 AND condition2
    • 默认情况下,[condition1, condition2, condition3] 等同于 ['&', condition1, '&', condition2, condition3]
  2. '|' (OR):
    • ['|', condition1, condition2] 表示 condition1 OR condition2
    • 要实现 A OR B OR C,可以写成 ['|', condition1, '|', condition2, condition3]
  3. '!' (NOT):
    • ['!', condition] 表示 NOT conditioncondition 必须是一个合法的、完整的Domain表达式(通常是一个三元组)。
    • 例如, ['!', ('state', '=', 'done')] 表示 state IS NOT 'done' (等同于 ('state', '!=', 'done'))。

组合与嵌套:

逻辑操作符可以嵌套使用以构建非常复杂的查询。每个逻辑操作符(&, |, !)都算作列表中的一个元素,后面跟着它所作用的条件(或子Domain)。

  • 示例1: (A AND B) OR C
domain = [
    '|',
        '&',
            ('field_a', '=', True),      # Condition A
            ('field_b', '>', 10),        # Condition B
        ('field_c', 'ilike', 'test%')  # Condition C
]
  • 示例2: A AND (B OR C)
domain = [
    ('field_a', '=', True),             # Condition A (隐式 AND 在最外层)
    '|',
        ('field_b', '>', 10),           # Condition B
        ('field_c', 'ilike', 'test%')     # Condition C
]
# 或者更明确地使用 '&':
domain = [
    '&',
        ('field_a', '=', True),         # Condition A
        '|',
            ('field_b', '>', 10),       # Condition B
            ('field_c', 'ilike', 'test%') # Condition C
]
  • 示例3: NOT (A AND B)
domain = [
    '!',
        '&',
            ('field_a', '=', True),
            ('field_b', '>', 10)
]

四、实战演练:Domain 查询场景示例

以下是一些从易到难的实际业务场景及其对应的Domain表达式。

场景1: 查找所有状态为“草稿”且金额大于100的销售订单。

  • 模型: sale.order
  • 字段: state (selection), amount_total (float)
  • Domain (Python):
domain = [
    ('state', '=', 'draft'),
    ('amount_total', '>', 100)
]

场景2: 查找本月创建且销售员是当前登录用户的所有潜在客户 (Lead/Opportunity)。

  • 模型: crm.lead
  • 字段: create_date (datetime), user_id (Many2one to res.users)
  • Domain (Python):
from odoo.fields import Date, Datetime
from datetime import date, datetime, time

today = date.today()
first_day_current_month_dt = datetime.combine(today.replace(day=1), time.min)

if today.month == 12:
    first_day_next_month_dt = datetime.combine(today.replace(year=today.year + 1, month=1, day=1), time.min)
else:
    first_day_next_month_dt = datetime.combine(today.replace(month=today.month + 1, day=1), time.min)

domain = [
    ('create_date', '>=', Datetime.to_string(first_day_current_month_dt)),
    ('create_date', '<', Datetime.to_string(first_day_next_month_dt)),
    ('user_id', '=', self.env.user.id)
]
# 注意:self.env.user.id 假设此代码在Odoo模型方法内执行。
# 如果在XML中,通常使用 `uid` 来代表当前用户ID。

场景3: 查找产品类别为“办公用品”或“电子产品”,并且是在售状态 (sale_ok=True) 的所有产品。

  • 模型: product.template (或 product.product)
  • 字段: categ_id (Many2one to product.category), name (on product.category), sale_ok (boolean)
  • Domain (Python):
# 假设你知道类别名称
domain_by_category_name = [
    ('sale_ok', '=', True),
    '|',
        ('categ_id.name', '=', '办公用品'), # Category name may change, not robust
        ('categ_id.name', '=', '电子产品')
]

# 更稳健的方式是使用类别的外部ID或数据库ID
# office_supply_category_id = self.env.ref('your_module.office_supply_category_xml_id').id
# electronics_category_id = self.env.ref('your_module.electronics_category_xml_id').id
# domain_by_category_id = [
#     ('sale_ok', '=', True),
#     ('categ_id', 'in', [office_supply_category_id, electronics_category_id])
# ]

场景4: 查找所有未开票(或部分开票)的已确认销售订单。

  • 模型: sale.order
  • 字段: invoice_status (selection: 'no', 'to invoice', 'invoiced', 'upselling'), state (selection: 'draft', 'sent', 'sale', 'done', 'cancel')
  • Domain (Python):
domain = [
    ('state', 'in', ['sale', 'done']), # 订单已确认或完成
    ('invoice_status', 'in', ['to invoice', 'no', 'upselling']) # 未完全开票的状态
    # 或者更简单,如果只想排除已完全开票的:
    # ('invoice_status', '!=', 'invoiced')
]

场景5: 查找所有分配给“支持团队”(及其子团队)的,优先级为“高”或“紧急”,并且在过去7天内未更新的任务。

  • 模型: project.task
  • 字段: team_id (Many2one to crm.team, 假设团队有层级), priority (selection '0'低, '1'中, '2'高, '3'紧急), write_date (datetime)
  • Domain (Python):
from odoo.fields import Datetime
from datetime import datetime, timedelta

seven_days_ago_dt = datetime.now() - timedelta(days=7)
support_main_team_id = self.env.ref('your_module.support_main_team_xml_id').id # 获取主支持团队ID

domain = [
    ('team_id', 'child_of', support_main_team_id), # 支持团队及其所有子团队
    ('priority', 'in', ['2', '3']),             # 优先级为高或紧急
    ('write_date', '<=', Datetime.to_string(seven_days_ago_dt)) # 最后更新日期在7天前或更早
]

五、Domain 在不同上下文中的应用

1. Python 代码中的 Domain

在Python代码中,Domain直接以列表的形式定义和使用,通常传递给模型的 search(), search_count(), name_search() 等方法,或者作为模型字段的 domain 属性(一个返回Domain列表的函数)。

# 在Python模型方法中
def find_urgent_tasks(self):
    domain = [('priority', '=', '3'), ('stage_id.is_closed', '=', False)]
    urgent_tasks = self.env['project.task'].search(domain)
    return urgent_tasks

class MyModel(models.Model):
    _name = 'my.model'
    partner_id = fields.Many2one(
        'res.partner',
        domain="[('is_company', '=', True)]" # 也可以是字符串,但列表更安全
    )
    # 或者动态domain
    # partner_id = fields.Many2one('res.partner', domain=lambda self: self._get_partner_domain())
    # def _get_partner_domain(self):
    #   # ... logic to build and return a domain list
    #   return [('customer_rank', '>', 0)]

Python中的Domain非常灵活,可以动态构建,使用变量、函数调用等。

2. XML 视图中的 Domain

在XML视图中(例如搜索视图 <filter>, 字段 <field domain="...">, 菜单动作 <act_window domain="...">),Domain以字符串形式书写,但其内部结构仍然遵循列表和元组的规则。

<filter string="My Leads" name="my_leads"
    domain="[('user_id', '=', uid)]"/>

<field name="product_id" domain="[('sale_ok', '=', True), ('type', '=', 'product')]"/>

<record id="action_invoiced_sales" model="ir.actions.act_window">
    <field name="name">Invoiced Sales Orders</field>
    <field name="res_model">sale.order</field>
    <field name="view_mode">tree,form</field>
    <field name="domain">[('invoice_status', '=', 'invoiced')]</field>
</record>

XML Domain 中的特殊变量:

  • uid: 当前登录用户的ID。
  • current_date: 当前日期字符串 (例如 '2023-12-25')。
  • context_today(): 返回当前日期的字符串,适用于 Date 字段。
  • time: 允许访问 time 模块中的函数,例如 time.strftime('%Y-%m-01') 获取本月第一天。
  • parent.field_name: 在视图的某些上下文中(如 One2many 字段的内嵌视图),可以用来引用父记录的字段值。
  • context.get('key'): 可以获取当前上下文中的值。

主要区别总结:

特性

Python Domain

XML Domain

格式

列表 (List of tuples/operators)

字符串 (String representation of list of tuples/operators)

动态性

非常高,可使用Python逻辑任意构建

有限,主要依赖固定值、uid, parent, 上下文变量等

求值时机

Python代码执行时

视图加载时、动作执行时、字段交互时

变量使用

Python变量, self.env.user, fields.Date

uid, current_date, parent.field_name, context.get()


六、高级技巧与注意事项

  1. False vs None:
    • 对于非关系型字段,('field', '=', False) 通常用于检查布尔值为False或数值为0。
    • 对于 Many2one 字段, ('many2one_field_id', '=', False) 用于查找该字段未设置(为NULL)的记录。
    • ('char_field', '=', False) 用于查找字符字段为空字符串 ''NULL 的记录。
  2. 动态构建Domain (Python):

在Python中,你可以根据条件逐步构建Domain列表:

domain = []
if some_condition:
    domain.append(('field_x', '=', value_x))
if another_condition:
    # 如果需要 OR
    if domain and use_or_logic: # 确保 domain 不为空,避免 [ '|', ('field_y', '<', value_y)] 这样的无效domain
        domain = ['|'] + domain + [('field_y', '<', value_y)]
    else: # 默认 AND
        domain.append(('field_y', '<', value_y))
records = self.env['my.model'].search(domain)
  1. Domain 中的占位符 (XML):

在某些XML上下文中,如服务器动作或报表动作,可以使用 %(variable)s 格式的占位符,这些占位符会从上下文中取值。例如 %(active_id)s, %(active_ids)s

  1. 性能考量:
    • 在复杂关系字段上进行过滤 (例如 'many2many_field.related_field.name', 'ilike', 'test') 可能会导致性能下降,因为数据库需要执行JOIN操作。
    • 确保被频繁用于Domain查询的字段(尤其是等值比较和排序)已建立数据库索引。
    • 尽量避免在非常大的数据集上使用 not innot ilike,除非有特定需求且已评估性能。
  2. Domain返回空: 如果一个 domain 属性的函数在Python中返回一个空列表 [],它意味着没有限制,即匹配所有记录。如果你想匹配任何记录都不匹配(返回空集),可以使用一个永远为假的条件,例如 [('id', '=', False)][(1, '=', 0)]

掌握Odoo的Domain语法是进行高效数据操作和定制的基础。通过不断实践和探索这些示例,您将能够构建出满足各种复杂业务需求的强大查询。


五、ORM 装饰器深度解析

1. 引言:Python 装饰器在 Odoo ORM 中的角色

在Python中,装饰器 (Decorator) 是一种特殊的设计模式,它允许程序员在不修改原始函数定义的情况下,动态地向函数或方法添加额外的功能。装饰器本质上是一个接收函数作为参数并返回一个新函数(或修改后的原函数)的函数。

Odoo框架广泛使用装饰器来将其核心ORM行为和业务逻辑注入到用户定义的模型方法中。这些装饰器充当了模型方法与Odoo框架生命周期事件之间的桥梁,使得开发者可以通过简洁明了的语法,让自定义方法在特定时机(如数据变更、记录创建、用户界面交互等)被自动调用。它们是实现Odoo声明式编程风格、自动化数据处理和构建响应式用户体验的关键工具。

本白皮书将聚焦于odoo.api模块提供的核心装饰器,这些装饰器是Odoo模块开发中不可或缺的一部分。


2. 核心 ORM 装饰器详解

2.1 @api.depends(*args)
  • 2.1.1 功能概述:

@api.depends装饰器专门用于计算字段 (Computed Fields)。它声明了计算字段的值依赖于哪些其他字段。当这些依赖字段的值发生变化时,Odoo ORM会自动标记该计算字段为“需要重新计算”,并在下次访问该字段时(对于非存储字段)或在依赖字段写入时(对于store=True的字段)触发其计算方法的执行。

  • 2.1.2 self 的含义与参数:
    • self: 在被@api.depends装饰的方法内部,self是一个记录集 (Recordset),包含了所有需要为其计算该字段值的记录。因此,方法体内部通常需要遍历self中的每一条记录进行赋值。
    • *args (str): 一个或多个字符串参数,代表依赖字段的名称。可以使用点号.来表示对关联模型字段的依赖,例如'partner_id.country_id.code'
  • 2.1.3 触发时机与底层机制:
    • 非存储计算字段 (store=False, 默认): 计算方法在每次读取该计算字段时触发。ORM会检查依赖项自上次计算以来是否有变化,如有必要则重新计算。
    • 存储计算字段 (store=True): 计算方法在任何其依赖字段被创建或写入时触发。计算结果会存储在数据库中。ORM会跟踪依赖链,当依赖项的值发生变化时,所有依赖于它的存储计算字段会被重新计算并更新。
    • 机制: Odoo ORM维护一个依赖图。当依赖字段的值通过ORM写入时,框架会检查哪些计算字段依赖于此字段。对于store=True的字段,会立即安排重新计算。对于store=False的字段,会在读取时根据需要重新计算。
  • 2.1.4 典型使用场景与最佳实践:
    • 动态计算字段值,如订单总额(依赖订单行)、年龄(依赖生日)。
    • 当字段值可以从其他字段派生,且需要保持同步时。
    • 对于复杂的依赖关系(如跨多层关联模型的字段),@api.depends能够清晰地声明这些依赖。
    • 最佳实践:
      • 依赖列表应尽可能精确,避免不必要的重计算。
      • 计算方法应高效,特别是对于store=True的字段,因为它们会在写操作时触发。
      • 对于非存储字段,如果计算开销大且频繁访问,考虑将其改为存储字段或优化计算逻辑。
  • 2.1.5 代码示例:
from odoo import models, fields, api

class SaleOrderLine(models.Model):
    _name = 'sale.order.line'
    _description = 'Sale Order Line'

    product_id = fields.Many2one('product.product', string='Product')
    quantity = fields.Float(string='Quantity', default=1.0)
    price_unit = fields.Float(string='Unit Price')
    discount = fields.Float(string='Discount (%)', digits='Discount', default=0.0)

    # 计算字段:小计金额 (存储)
    price_subtotal = fields.Monetary(
        string='Subtotal',
        compute='_compute_price_subtotal',
        store=True, # 结果将存储在数据库
        currency_field='currency_id'
    )
    currency_id = fields.Many2one(related='order_id.currency_id') # 假设order_id存在
    order_id = fields.Many2one('sale.order', string='Order Reference')


    @api.depends('quantity', 'price_unit', 'discount')
    def _compute_price_subtotal(self):
        for line in self:
            price = line.price_unit * (1 - (line.discount or 0.0) / 100.0)
            line.price_subtotal = price * line.quantity
            # 如果没有依赖 discount,但 discount 变了,price_subtotal 不会重算 (除非 store=False)
2.2 @api.onchange(*fields)
  • 2.2.1 功能概述:

@api.onchange装饰器用于定义当用户在表单视图 (Form View) 中修改指定字段的值时,需要自动执行的方法。此方法可以用来动态改变表单中其他字段的值、设置字段的域 (domain)、或显示警告信息给用户。它的行为是即时的,主要用于改善用户交互体验。

  • 2.2.2 self 的含义与参数:
    • self: 在被@api.onchange装饰的方法内部,self代表当前表单视图中的单条记录。这是一个“虚拟”记录,可能尚未保存到数据库(对于新建记录)或者包含了用户尚未保存的修改。因此,self.id可能是False或一个临时的NewId
    • *fields (str): 一个或多个字符串参数,代表在表单视图中监控其变化的字段名称。
  • 2.2.3 触发时机与底层机制:
    • 当用户在表单视图中修改并离开 (失去焦点) 任何一个被@api.onchange方法所监控的字段时,Web客户端会向Odoo服务器发起一个RPC(远程过程调用)请求。
    • 服务器接收到请求后,会实例化对应模型的当前表单记录(可能只包含已更改的值和ID),然后调用相应的onchange方法。
    • 方法执行完毕后,其对self所做的修改(改变其他字段值)会连同可能的警告或域更新信息一并返回给客户端,客户端再更新表单视图的显示。
    • 重要: onchange方法不直接写入数据库。它仅修改内存中当前表单记录的字段值。这些更改需要用户点击“保存”才会持久化。
  • 2.2.4 典型使用场景与最佳实践:
    • 根据一个字段的值自动填充或更新其他字段(例如,选择产品后自动填充单价和描述)。
    • 动态改变某个字段的可选范围(domain)(例如,根据选择的国家动态过滤可选的州/省份)。
    • 向用户显示基于当前输入的警告或提示信息。
    • 最佳实践:
      • onchange方法应快速执行,避免长时间阻塞用户界面。
      • 避免在onchange方法中执行复杂的数据库查询或业务逻辑,这些应留给保存时的计算字段或约束。
      • 返回的domainwarning应清晰明了。
      • 如果一个字段的逻辑完全由其他字段派生且不需要用户交互调整,优先考虑使用计算字段 (@api.depends)。
  • 2.2.5 代码示例:
from odoo import models, fields, api

class CrmLead(models.Model):
    _name = 'crm.lead'
    _description = 'Lead/Opportunity'

    partner_id = fields.Many2one('res.partner', string='Customer')
    contact_name = fields.Char(string='Contact Name')
    phone = fields.Char(string='Phone')
    email = fields.Char(string='Email')
    expected_revenue = fields.Float(string='Expected Revenue')
    stage_id = fields.Many2one('crm.stage', string='Stage')

    @api.onchange('partner_id')
    def _onchange_partner_id(self):
        if self.partner_id:
            self.contact_name = self.partner_id.name # 假设客户是个人
            self.phone = self.partner_id.phone
            self.email = self.partner_id.email
            # 还可以为其他字段设置 domain
            # return {'domain': {'some_field_id': [('partner_id', '=', self.partner_id.id)]}}
        else:
            self.contact_name = False
            self.phone = False
            self.email = False

    @api.onchange('expected_revenue')
    def _onchange_expected_revenue(self):
        if self.expected_revenue < 0:
            self.expected_revenue = 0 # 修正无效输入
            return {
                'warning': {
                    'title': "Validation Error",
                    'message': "Expected revenue cannot be negative."
                }
            }
2.3 @api.constrains(*args)
  • 2.3.1 功能概述:

@api.constrains装饰器用于定义模型级别的数据验证约束 (Constraints)。这些约束是Python方法,用于在记录创建或更新后检查数据是否满足特定的业务规则。如果约束未被满足,方法应抛出odoo.exceptions.ValidationError异常,阻止不合法的数据保存到数据库。

  • 2.3.2 self 的含义与参数:
    • self: 在被@api.constrains装饰的方法内部,self是一个记录集,包含了所有刚刚被创建或修改过并且其被监控字段发生变化的记录。方法需要遍历self中的每条记录来执行验证。
    • *args (str): 一个或多个字符串参数,代表此约束所依赖的字段名称。只有当这些指定字段中的至少一个在创建或写入操作中被设置或修改时,此约束方法才会被触发检查。
  • 2.3.3 触发时机与底层机制:
    • 约束检查在ORM的create()write()方法成功执行(即数据已准备好写入或已写入缓存但未提交到数据库)之后,但在事务实际提交之前触发。
    • ORM会收集所有被修改记录上定义的约束,并且只调用那些依赖字段包含在当前写操作或创建数据中的约束方法。
    • 如果任何约束方法抛出ValidationError,整个写操作或创建操作将被回滚,数据库不会发生任何更改,错误信息会显示给用户。
  • 2.3.4 典型使用场景与最佳实践:
    • 确保字段值满足特定格式(如身份证号、邮箱格式,尽管这些也可用字段本身的验证)。
    • 实现跨字段的复杂业务规则(如订单的开始日期不能晚于结束日期)。
    • 检查记录的唯一性(尽管_sql_constraints通常更适合简单的唯一性约束)。
    • 最佳实践:
      • 约束方法应只关注验证逻辑,不应有副作用(如修改其他记录)。
      • 错误信息应清晰,准确告知用户违反了什么规则以及如何修正。
      • 依赖字段列表*args应尽可能精确,以避免不必要的约束检查。
      • 对于数据库级别的简单约束(如NOT NULL, UNIQUE),优先使用模型的_sql_constraints属性,因为它们通常更高效。
  • 2.3.5 代码示例:
from odoo import models, fields, api
from odoo.exceptions import ValidationError

class EventRegistration(models.Model):
    _name = 'event.registration'
    _description = 'Event Registration'

    event_id = fields.Many2one('event.event', string='Event', required=True)
    attendee_name = fields.Char(string='Attendee Name', required=True)
    registration_date = fields.Date(string='Registration Date', default=fields.Date.today)
    event_start_date = fields.Date(related='event_id.date_begin', string='Event Start Date', store=True)

    @api.constrains('registration_date', 'event_start_date')
    def _check_registration_before_event_start(self):
        for registration in self:
            if registration.registration_date and registration.event_start_date:
                if registration.registration_date > registration.event_start_date:
                    raise ValidationError("Registration date cannot be after the event's start date!")

    # 另一个例子:确保活动名不是 "Test Event"
    @api.constrains('event_id')
    def _check_event_name(self):
        for record in self:
            if record.event_id and 'test event' in record.event_id.name.lower():
                 raise ValidationError("Cannot register for 'Test Event's.")
2.4 @api.model
  • 2.4.1 功能概述:

@api.model装饰器用于声明一个方法是模型级别的方法,而不是记录级别的方法。这意味着该方法不依赖于具体的记录实例(即self中没有特定的记录ID)。它通常用于不操作特定记录数据,而是提供模型范围内的功能或作为工具方法。

  • 2.4.2 self 的含义与参数:
    • self: 在被@api.model装饰的方法内部,self是一个代表当前模型的空记录集 (e.g., res.partner())。它主要用作访问环境 (self.env) 和调用其他模型级别ORM方法(如 self.search(), self.create(), self.browse())的入口点。它不包含任何特定记录的数据。
  • 2.4.3 触发时机与底层机制:
    • 这类方法通常由其他代码显式调用,例如从控制器、其他模型的服务方法、计划任务,或作为模型的特定ORM方法(如create, default_get, fields_get等)的实现。
    • 框架识别到@api.model后,在调用方法时会传入一个空的记录集作为self
  • 2.4.4 典型使用场景与最佳实践:
    • 实现模型的create()方法(现代Odoo中create方法签名通常为def create(self, vals_list):,它需要处理批量创建)。
    • 实现default_get(fields_list)方法以提供字段的默认值。
    • 实现fields_get()方法以动态修改字段定义。
    • 定义不与特定记录绑定的辅助函数或工具方法(例如,数据导入/导出逻辑、与外部API交互的静态方法)。
    • 作为RPC调用的端点,提供不依赖于特定记录的服务。
    • 最佳实践:
      • 清晰区分模型级别和记录级别的方法。如果一个方法不需要访问self中特定记录的字段值,它很可能应该是一个@api.model方法。
      • create方法中,正确处理vals_list以支持批量创建。
  • 2.4.5 代码示例:
from odoo import models, fields, api

class ResPartner(models.Model):
    _inherit = 'res.partner'

    @api.model
    def get_partner_types(self):
        # 这是一个模型级别的工具方法,返回可用的伙伴类型
        return [('contact', 'Contact'), ('invoice', 'Invoice Address'), ('delivery', 'Delivery Address')]

    @api.model
    def create(self, vals_list):
        # 重写create方法,可能添加一些默认逻辑或日志
        # vals_list 是一个字典列表,即使只创建一个记录,也包装在列表中
        # 在 Odoo 14+,vals_list 参数是标准,旧版本可能是 vals (单个字典)
        # 现在的基类 create 方法能很好地处理单个字典和字典列表
        _logger.info(f"Creating partner(s) with vals: {vals_list}")
        # 对于 vals_list 中的每个 vals 字典,可以进行预处理
        for vals in vals_list:
            if 'name' in vals and not vals.get('display_name'):
                vals['display_name'] = vals['name']
        
        # 调用父类的 create 方法
        partners = super(ResPartner, self).create(vals_list)
        _logger.info(f"Created partner(s): {partners.ids}")
        # 可以进行创建后的操作
        return partners

    def some_record_level_method(self):
        # self 在这里是一个包含一条或多条记录的记录集
        partner_types = self.get_partner_types() # 调用模型级别方法
        for record in self:
            _logger.info(f"Partner {record.name} types available: {partner_types}")
2.5 @api.model_create_multi@api.returns
  • 2.5.1 @api.model_create_multi(deprecated_create_function=None):
    • 功能概述: 这个装饰器主要用于标记模型的 create 方法,以表明它支持(并且期望)批量创建。在较新版本的Odoo (14+) 中,create 方法的标准签名变为 create(self, vals_list),其中 vals_list 是一个包含待创建记录的值字典的列表。
    • @api.model_create_multi 装饰器本身在 odoo/models.py 的基类 Model 中用于其 create 方法。当开发者在自定义模型中重写 create 方法时,通常会使用 @api.model,并确保其方法签名是 def create(self, vals_list): 以正确处理批量创建。该装饰器更多是框架内部用于确保 create 方法的一致性和对旧式单 vals 调用的兼容处理。
    • 开发者通常直接使用 @api.model 装饰 create 方法,并遵循 (self, vals_list) 的签名。
    • 示例 (标准 create 重写): (见上方 @api.modelcreate 示例)
  • 2.5.2 @api.returns(model, downgrade=None, upgrade=None):
    • 功能概述: @api.returns装饰器用于显式声明一个方法返回的记录集属于哪个模型。这有助于ORM进行类型检查,改善方法链式操作的可靠性,并能让IDE更好地进行代码提示。
    • model (str): 返回记录集的模型名称,例如 'res.partner'。如果方法返回的是 self(即与方法所属模型相同的记录集),可以写成 self
    • downgrade (callable, optional): 一个错误处理函数,当方法返回的记录集不符合预期(例如,预期单例却返回多条)时调用。
    • upgrade (callable, optional): 另一个错误处理函数。
    • 典型使用场景:
      • 当一个方法可能返回不同模型的记录,或者其返回类型不明显时。
      • 在定义抽象方法或mixin类中的方法时,确保实现者返回正确的模型类型。
      • 用于确保方法返回单例记录(通过 downgrade 参数配合检查)。
    • 示例:
from odoo import models, fields, api

class ResPartner(models.Model):
    _inherit = 'res.partner'

    @api.returns('res.partner') # 明确指出返回的是 res.partner 记录集
    def get_similar_partners(self, min_similarity_score=0.7):
        # 假设这里有复杂的逻辑来查找相似伙伴
        # ...
        similar_partner_ids = self._find_similar_ids(min_similarity_score)
        return self.browse(similar_partner_ids)

    @api.returns('self', downgrade=lambda self, value, *args, **kwargs: value[0] if value else self.browse())
    def get_main_contact(self):
        # 这个方法应该只返回一个联系人 (或空)
        # 如果 self.contact_ids 包含多个,downgrade 会取第一个
        # (这是一个简化的例子,实际的单例保证可能更复杂)
        if self.is_company and self.child_ids:
            # 查找标记为 'contact' 类型的子联系人
            contacts = self.child_ids.filtered(lambda c: c.type == 'contact')
            return contacts # 如果 contacts 有多个,downgrade 会处理
        return self.env['res.partner'] # 返回空记录集

3. @api.depends@api.onchange 的深度比较

特性

@api.depends (计算字段)

@api.onchange (表单交互)

主要目的

根据其他字段自动计算并(可选)存储字段值。数据派生与一致性。

响应用户在表单视图中的字段修改,动态更新表单其他部分或提供反馈。改善用户体验。

self 上下文

记录集 (Recordset),包含所有需要计算的记录。方法需遍历。

单条记录 (Singleton-like),代表表单当前状态(可能未保存)。

触发时机

- store=False: 读取该计算字段时。<br>- store=True: 其依赖字段被创建或写入时。

用户在表单视图修改并离开被监控字段时 (通过RPC调用)。

执行环境

服务器端,数据驱动。

客户端发起,服务器端执行,UI驱动。

数据持久化

- store=True: 计算结果存储在数据库。<br>- store=False: 动态计算,不直接存储。

不直接写入数据库。修改内存中表单数据,需用户保存才持久化。

返回值

无显式返回值;通过给 self 的记录赋值来设定计算字段的值。

可选返回字典:{'warning': {...}, 'domain': {'field': [...]}}。通过修改self的属性来改变表单字段值。

适用场景

自动派生数据、确保数据一致性、复杂计算逻辑。

动态表单行为、即时用户反馈、依赖输入的字段默认值、动态域过滤。

性能考量

- store=True 的计算会在写操作时增加开销。<br>- 依赖关系复杂或计算量大时需优化。

应快速执行避免UI卡顿。避免复杂DB查询。

批量操作

设计上能自然处理批量记录(因self是记录集)。

通常一次处理表单中的一条记录。

何时选择?

  • 如果一个字段的值是完全由其他字段的值确定的,并且这个逻辑应该在数据层面得到保证(无论数据是如何创建或修改的),那么使用@api.depends的计算字段是合适的。
  • 如果需要根据用户在表单中的即时输入动态改变其他字段的值、显示警告或更新字段可选范围,以提升用户交互体验,那么使用@api.onchange
  • 一个字段可以同时拥有计算逻辑 (@api.depends) 和交互逻辑 (@api.onchange)。例如,一个字段的默认值可能通过@api.onchange根据另一个字段设定,但其最终存储值可能通过@api.depends确保与其他数据的同步。

4. 装饰器关键特性速查表

装饰器

self 的含义

触发时机/条件

主要用途

返回值影响

@api.depends(*fields)

记录集 (需计算的记录)

读取计算字段 (store=False) 或依赖字段变更 (store=True)

定义计算字段的逻辑

无 (通过 self.field = value 赋值)

@api.onchange(*fields)

表单中的单条虚拟记录

用户在UI表单修改受监控字段

响应UI变化,更新表单其他字段,显示警告/domain

可选返回 {'warning': ..., 'domain': ...}。通过 self.field = value 更新表单值。

@api.constrains(*fields)

记录集 (被创建/修改的记录)

记录创建或更新后,且受监控字段发生变化

定义数据验证规则

无 (通过抛出 ValidationError 来阻止操作)

@api.model

空记录集 (模型本身)

方法被显式调用时;或作为特定ORM方法 (create, default_get) 的实现

模型级别的工具方法、createdefault_get

取决于方法设计

@api.returns(model_name)

取决于被装饰的方法

方法执行完毕返回结果时

声明方法返回的记录集模型,增强类型安全和链式操作

确保返回值是指定模型的记录集;可配合 downgrade 处理错误。


5. 结论与展望

Odoo ORM的装饰器是框架强大功能和灵活性的体现。它们使得开发者能够以声明式的方式将业务逻辑优雅地集成到模型的生命周期和用户交互中。深刻理解@api.depends, @api.onchange, @api.constrains, @api.model等核心装饰器的内部机制和适用场景,是编写高质量、高性能Odoo模块的基石。

随着Odoo框架的不断演进,可能会出现新的装饰器或现有装饰器行为的细微调整。因此,持续关注官方文档和社区讨论,并结合实践经验,将有助于开发者始终保持对Odoo框架内核的深入理解。


六、ORM 性能优化黄金法则

Odoo的ORM是一个强大而灵活的工具,但如果不加以注意,某些使用模式可能会导致显著的性能问题。理解ORM的行为并遵循最佳实践是确保Odoo应用流畅运行的关键。以下清单总结了最重要的性能优化技巧:

1. 攻克 N+1 查询问题:批量化你的读取操作!

问题分析 (N+1 问题):

N+1查询是最常见的性能杀手之一。它通常发生在当你首先获取一批主记录(1次查询),然后在循环中访问这些主记录的关联记录或字段时,每次访问都可能触发一次新的数据库查询(N次查询)。

  • 反模式 (Bad Practice): 在循环中访问关联对象的字段。
# orders 是一个 sale.order 的记录集
# orders = self.env['sale.order'].search([('state', '=', 'sale')], limit=100)
partner_names = []
for order in orders:
    # 每次访问 order.partner_id.name 都可能触发一次新查询(如果伙伴信息未被预取)
    # 特别是如果 partner_id 对应的伙伴各不相同,且 name 字段未预取
    partner_names.append(order.partner_id.name)
print(f"客户名称 (N+1 风险): {partner_names}")
  • 推荐模式 (Good Practice):
    • a) 使用 mapped() 预先批量获取数据:
# orders = self.env['sale.order'].search([('state', '=', 'sale')], limit=100)
# .mapped() 会尝试高效地获取所有相关伙伴的名称
# Odoo的预取机制在这里会发挥作用
partner_names_mapped = orders.mapped('partner_id.name')
print(f"客户名称 (使用 mapped): {partner_names_mapped}")
    • b) 使用 read() 批量读取所需字段:
# orders = self.env['sale.order'].search([('state', '=', 'sale')], limit=100)
# 一次性读取所有订单的 'name' 和 'partner_id' (的name_get结果或ID)
# 如果只需要 partner_id 的特定字段,考虑 search_read 组合或后续处理
orders_data = orders.read(['name', 'partner_id']) # partner_id会返回 (id, name_get_display)
processed_data = []
for data in orders_data:
    # data['partner_id'] is a tuple (id, display_name) or False
    partner_display_name = data['partner_id'][1] if data['partner_id'] else 'N/A'
    processed_data.append({'order_name': data['name'], 'partner_name': partner_display_name})
print(f"订单与客户 (使用 read): {processed_data}")
    • c) 如果需要关联对象的多个字段,可以先获取关联对象集,再操作:
# orders = self.env['sale.order'].search([('state', '=', 'sale')], limit=100)
partners = orders.mapped('partner_id') # 获取所有相关的伙伴记录集 (去重)
# 现在 partners 是一个 res.partner 记录集,可以安全地访问其字段
# Odoo的预取机制会为这个 partners 记录集批量获取字段
for partner in partners:
    print(f"客户: {partner.name}, 邮箱: {partner.email}") # 访问已预取的字段

为何更优? mapped()read() (以及Odoo的预取机制)会将多次单独的数据库查询合并为少数几次(甚至一次)批量查询,极大地减少了数据库的往返次数和查询开销。

2. 拥抱 search_read():一步到位获取数据

问题分析:

常见的模式是先用 search() 获取记录ID,然后在循环中 browse() 这些ID(或直接操作 search() 返回的记录集)并逐个访问字段。这可能导致多次数据获取调用。

  • 反模式 (Bad Practice):
domain = [('type', '=', 'contact')]
# contact_ids = self.env['res.partner'].search(domain, limit=50).ids # 获取ID列表
# contacts_data = []
# for contact_id in contact_ids:
#     partner = self.env['res.partner'].browse(contact_id)
#     contacts_data.append({'name': partner.name, 'email': partner.email})

# 或者,即使是操作记录集也可能不如 search_read 高效(如果预取未覆盖所有需求或有大量字段)
contacts_rs = self.env['res.partner'].search(domain, limit=50)
contacts_data_rs = []
for partner in contacts_rs:
    contacts_data_rs.append({'name': partner.name, 'email': partner.email, 'phone': partner.phone})
  • 推荐模式 (Good Practice):
domain = [('type', '=', 'contact')]
fields_to_read = ['name', 'email', 'phone']
# 一次查询直接获取所有需要的数据字典列表
contacts_data = self.env['res.partner'].search_read(domain, fields=fields_to_read, limit=50)
for data in contacts_data:
    print(f"姓名: {data['name']}, 邮箱: {data.get('email')}") # data.get()更安全

为何更优? search_read() 将搜索和读取操作合并为单次数据库调用,直接返回包含所需字段数据的字典列表。这减少了ORM对象的创建开销和数据处理步骤,尤其是在只需要字段值而非完整记录对象时。

3. 深入理解并善用ORM预取 (Prefetching) 机制

机制解释:

Odoo ORM非常智能。当你访问一个记录集(例如 my_records)中某个记录的字段(例如 my_records[0].some_field)时,如果该字段尚未为这个记录集中的所有记录获取数据,ORM会自动为当前记录集中的所有记录(或一个内部定义的批次大小,通常是1000条)预先获取该字段的值。如果该字段是关系字段(如Many2one),后续访问其属性(如 my_records[0].some_field_id.name)时,ORM会进一步为所有这些关联记录预取name字段。

  • 如何利用它:
    • 信任它:在许多情况下,直接迭代记录集并访问字段(如 for record in recordset: print(record.related_id.name))已经因为预取机制而变得相当高效。Odoo会尝试分批获取 related_id,然后再分批获取这些 related_idname
    • 组织你的访问:尽量将对同一类型数据的访问组织在一起。例如,先 mapped() 出所有需要的关联对象,然后再处理这个新的记录集。
    • _prefetch_fields: 模型上可以定义 _prefetch_fields = ['field1', 'field2'] 列表,指示这些字段在记录被浏览时应被优先预取。这是一种更细粒度的控制,但通常ORM的自动预取已经足够。
  • 示例 (预取通常使其高效):
# sale_orders 是一个包含多个订单的记录集
# sale_orders = self.env['sale.order'].search([], limit=50)

# 反模式 (如果手动分批且批次太小,或者没有意识到预取)
# --- (难以用简单代码展示反模式,因为预取是ORM行为)---

# 推荐模式 (依赖Odoo的自动预取)
print("--- 访问订单名称和客户名称 ---")
for order in sale_orders:
    # 1. 当第一次访问 order.name 时,Odoo会为 sale_orders 批次中的所有订单预取 name 字段。
    # 2. 当第一次访问 order.partner_id 时,Odoo会为 sale_orders 批次中的所有订单预取 partner_id 对象。
    # 3. 当第一次访问 partner_id.name (即 order.partner_id.name) 时,
    #    Odoo会为从步骤2中获取的所有 partner_id 对象(去重后)批次预取 name 字段。
    print(f"订单: {order.name}, 客户: {order.partner_id.name}")

print("\n--- 使用 mapped 也能很好地利用预取 ---")
# .mapped() 内部也会充分利用预取机制
partner_vat_list = sale_orders.mapped('partner_id.vat')
for vat in partner_vat_list:
    if vat:
        print(f"客户VAT: {vat}")

为何重要? 理解预取可以让你避免不必要的手动优化,并相信ORM在很多情况下能高效处理数据。但也要意识到它的批次限制,对于超大规模数据集,仍需谨慎。

4. 使用 read_group() 进行高效数据聚合

问题分析:

当需要按某些字段分组并计算聚合值(如总和、平均值、计数)时,在Python中循环处理大量数据效率低下。

  • 反模式 (Bad Practice):
# 按销售员分组计算订单总额
# orders = self.env['sale.order'].search([('state', 'in', ['sale', 'done'])])
sales_by_user = {}
for order in orders:
    user_id = order.user_id.id
    sales_by_user.setdefault(user_id, 0)
    sales_by_user[user_id] += order.amount_total
# print(sales_by_user)
  • 推荐模式 (Good Practice):
# domain_sales = [('state', 'in', ['sale', 'done'])]
# fields_to_group = ['user_id'] # 按销售员ID分组
# fields_to_aggregate = ['amount_total:sum'] # 计算总金额

# Odoo 16+ 推荐的聚合字段名格式,例如 'amount_total:sum'
# 旧版本可能需要 'amount_total' 并在结果中找 'amount_total' 和 '__count'

sales_data_grouped = self.env['sale.order'].read_group(
    domain=domain_sales,
    fields=['user_id', 'amount_total:sum', 'id:count'], # id:count 重命名为 user_id_count
    groupby=['user_id'],
    lazy=False # lazy=False 直接返回所有分组结果
)
# sales_data_grouped 会是类似这样的列表:
# [{'user_id': (id, name), 'user_id_count': N, 'amount_total': SUM, '__domain': domain_for_this_group}, ...]
for group in sales_data_grouped:
    user_name = group['user_id'][1] if group.get('user_id') else "Unassigned"
    total_sales = group['amount_total']
    order_count = group['user_id_count'] # 在groupby=['user_id']时,N个记录会使user_id_count=N
    print(f"销售员: {user_name}, 订单数: {order_count}, 总销售额: {total_sales}")

为何更优? read_group() 将分组和聚合操作下推到数据库层面执行 (SQL GROUP BY 和聚合函数)。数据库在这些操作上经过高度优化,远比Python层面处理快得多,并极大减少了需要传输到应用服务器的数据量。

5. 智能计数:search_count() 优于 len(search())

问题分析:

获取满足条件的记录数量时,如果先 search()len(),会不必要地将所有符合条件的记录(或至少是它们的ID)加载到内存中。

  • 反模式 (Bad Practice):
# domain = [('state', '=', 'draft')]
# draft_orders_count = len(self.env['sale.order'].search(domain))
# print(f"草稿订单数量 (低效): {draft_orders_count}")
  • 推荐模式 (Good Practice):
# domain = [('state', '=', 'draft')]
# draft_orders_count = self.env['sale.order'].search_count(domain)
# print(f"草稿订单数量 (高效): {draft_orders_count}")

为何更优? search_count() 直接在数据库执行 SQL COUNT(*) 查询,只返回一个数字,速度极快,内存占用极小。

6. 批量写操作:create(), write(), unlink() 的威力

问题分析:

在循环中逐条创建、更新或删除记录,每次操作都会产生一次数据库交互。

  • 反模式 (Bad Practice):
# product_vals_list = [{'name': 'P1'}, {'name': 'P2'}, {'name': 'P3'}]
# created_products_ids = []
# for vals in product_vals_list:
#     new_product = self.env['product.product'].create(vals)
#     created_products_ids.append(new_product.id)
# print(f"逐条创建的产品ID: {created_products_ids}")

# records_to_update = self.env['res.partner'].search([('active','=',False)], limit=3)
# for record in records_to_update:
#    record.write({'active': True})
  • 推荐模式 (Good Practice):
product_vals_list = [{'name': 'P1_batch'}, {'name': 'P2_batch'}, {'name': 'P3_batch'}]
# 批量创建
created_products = self.env['product.product'].create(product_vals_list)
print(f"批量创建的产品ID: {created_products.ids}")

# 批量更新
records_to_update = self.env['res.partner'].search([('active','=',False)], limit=3)
if records_to_update:
    records_to_update.write({'comment': 'Batch updated to active', 'active': True})
    print(f"批量更新了 {len(records_to_update)} 条伙伴记录。")

# 批量删除 (假设我们要删除上面创建的产品)
if created_products:
    created_products.unlink()
    print(f"批量删除了 {len(created_products)} 条产品记录。")

为何更优? ORM对批量操作有优化。create() 传入列表,write()unlink() 直接在记录集上操作,可以显著减少数据库的调用次数,提升整体性能。

7. 精准的Domain:在源头过滤数据

问题分析:

获取一个非常大的数据集,然后在Python层面使用 filtered() 进行过滤,会消耗大量内存和CPU。

  • 反模式 (Bad Practice):
# all_partners = self.env['res.partner'].search([])
# us_partners = all_partners.filtered(lambda p: p.country_id.code == 'US')
# print(f"在美国的伙伴数量 (Python过滤): {len(us_partners)}")
  • 推荐模式 (Good Practice):
# domain_us = [('country_id.code', '=', 'US')]
# us_partners_db_filtered = self.env['res.partner'].search(domain_us)
# print(f"在美国的伙伴数量 (数据库过滤): {len(us_partners_db_filtered)}")

为何更优? 尽可能将过滤条件通过 domain 参数传递给 search()search_read(),让数据库完成过滤工作。这比在Python内存中过滤要高效得多。

8. 谨慎使用存储的计算字段 (store=True, @api.depends)

问题分析:

store=True的计算字段会在其依赖字段发生改变时重新计算并写入数据库。如果依赖关系复杂、依赖字段变更频繁,或者计算逻辑耗时,可能会拖慢写操作的性能。

  • 建议:
    • 确保 @api.depends() 中的依赖列表尽可能精确和最小化。
    • 计算方法本身应高效。
    • 对于频繁变动但又需要搜索/分组的字段,权衡存储带来的写开销和不存储带来的读开销。
    • 有时,非存储的计算字段结合优化的搜索方法 (重写 _search 方法) 可能是更好的选择。

9. 明智地使用 filtered()sorted()

问题分析:

filtered()sorted() 是在Python内存中对已加载的记录集进行操作。如果对一个非常大的、可以通过数据库查询直接完成过滤或排序的记录集使用它们,性能会很差。

  • 建议:
    • 优先使用 search()domain 参数进行过滤,使用 order 参数进行排序。
    • filtered() 适用于:
      • 记录集已在内存中且规模较小。
      • 过滤逻辑非常复杂,无法用标准Domain表达。
    • sorted() 适用于:
      • 记录集已在内存中且规模较小。
      • 排序键是动态计算的,或标准 order 参数无法满足需求。

10. 分析与度量:不猜测,用数据说话!

问题分析:

没有性能分析的优化往往是盲目的,甚至可能引入新的问题。

  • 建议:
    • 开启SQL日志: 在Odoo启动参数中添加 --log-level debug_sql (或在配置文件设置 log_level = debug_sql),观察ORM生成的SQL查询,找出慢查询或N+1问题。
    • 使用Odoo性能分析工具: Odoo企业版有性能分析工具。对于社区版,可以通过服务器动作执行代码并记录时间,或者使用Python内置的 cProfilepstats 模块。
    • 简化问题: 将性能问题隔离到最小的可复现代码块。
    • 关注核心瓶颈: 通常20%的代码消耗了80%的资源 (帕累托法则)。

遵循这些最佳实践,将帮助您构建出响应更迅速、资源消耗更合理的Odoo应用程序。性能优化是一个持续的过程,关键在于理解ORM行为并结合实际场景进行分析和调整。


七、ORM 事务管理与原生SQL交互

Odoo的ORM(对象关系映射)层为开发者提供了便捷高效的数据访问接口,并内置了强大的事务管理机制以确保数据的一致性和完整性。然而,在某些特定场景下,开发者可能需要绕过ORM,直接与数据库进行SQL交互。本指南将深入探讨Odoo的事务处理,并详细阐述何时以及如何安全地执行原生SQL查询,同时重点强调相关的风险与最佳实践。


1. Odoo ORM 事务管理机制

事务是一系列数据库操作,这些操作要么全部成功执行,要么在发生任何错误时全部回滚,从而确保数据的原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)——即ACID属性。

1.1 自动事务处理

在Odoo中,标准的ORM方法调用(如 create(), write(), unlink(), search(), 以及自定义的模型方法)通常是自动包含在事务中的。

  • 开始事务: 当一个Odoo请求到达服务器(例如,用户通过界面操作,或外部系统通过API调用一个模型方法),Odoo会为该请求的数据库操作(通过当前环境的游标 self.env.cr)自动开启一个数据库事务。
  • 提交/回滚:
    • 如果方法成功执行完毕,没有抛出任何未被捕获的Python异常,Odoo会在请求结束时自动提交 (COMMIT) 当前事务,将所有数据更改永久保存到数据库。
    • 如果方法执行过程中抛出了任何未被捕获的Python异常(包括Odoo的 UserError, ValidationError 等),Odoo会自动回滚 (ROLLBACK) 当前事务,撤销在此事务中进行的所有数据更改,从而保证数据库状态的一致性。

这种自动事务管理极大地简化了开发,因为开发者通常不需要显式地处理事务的开始、提交或回滚。

1.2 事务保存点 (Savepoints): with self.env.cr.savepoint():

在某些复杂的业务逻辑中,你可能希望在一个大的事务中实现部分操作的回滚,而不是因为一个小错误就回滚整个事务。这时,可以使用事务保存点 (Savepoint)

保存点允许你在事务内部创建一个标记,如果后续操作失败,你可以回滚到这个保存点,而不是整个事务。Odoo通过Python的 with 语句和游标的 savepoint() 方法优雅地实现了这一点。

  • 演示:
from odoo import models, fields, api
from odoo.exceptions import UserError
import logging

_logger = logging.getLogger(__name__)

class MyTransactionDemo(models.Model):
    _name = 'my.transaction.demo'
    _description = 'Transaction Demo Model'

    name = fields.Char(required=True)
    value_a = fields.Integer()
    value_b = fields.Char()

    def process_complex_operation(self):
        self.ensure_one()
        _logger.info(f"开始处理记录: {self.name}")
        self.value_a = 100 # 初始操作

        try:
            # 使用保存点来包裹可能失败的部分操作
            with self.env.cr.savepoint():
                _logger.info("进入保存点,尝试更新 value_b...")
                self.value_b = "Updated in savepoint"
                if self.name == 'test_partial_rollback':
                    # 模拟一个错误,这将导致保存点内的操作被回滚
                    raise UserError("模拟保存点内的错误,value_b的更新将被回滚!")
                _logger.info("value_b 更新成功。")
            
            # 如果保存点内的代码成功执行(或错误被捕获且未重新抛出导致回滚),
            # 或者保存点因错误已回滚,代码会继续执行到这里。
            _logger.info("保存点处理完毕。")

        except UserError as e:
            # 这里捕获的是上面显式抛出的 UserError
            # 注意:如果错误导致保存点回滚,value_b 的更改会丢失
            _logger.warning(f"捕获到错误: {e}. value_a ({self.value_a}) 的更改仍然存在,但value_b ({self.value_b}) 可能已回滚。")
            # 开发者可以选择在这里是否重新抛出错误,或者进行其他处理
            # 如果不重新抛出,外部事务将继续并可能提交 value_a 的更改

        self.value_a = self.value_a + 50 # 外部事务的进一步操作
        _logger.info(f"操作完成,最终 value_a: {self.value_a}, value_b: {self.value_b}")

        # 如果这里再抛出错误,且未被最外层捕获,整个 process_complex_operation 的操作都会回滚
        # (包括初始的 self.value_a = 100 和 self.value_a += 50)
        # if self.name == 'test_full_rollback':
        #     raise ValueError("模拟外部事务的错误,所有更改都将回滚")

机制:

    • 进入 with self.env.cr.savepoint(): 块时,会自动创建一个数据库保存点。
    • 如果块内代码成功执行完毕,该保存点会被“释放”(但操作结果仍属于当前大事务,等待最终提交)。
    • 如果块内代码抛出异常,事务会自动回滚到进入该 with 块之前的状态(即回滚到保存点创建时的状态),然后异常会继续向上传播,除非在 with 块外部被捕获。

重要提示: 开发者应避免在标准的Odoo模型方法中直接调用 self.env.cr.commit()self.env.cr.rollback()。Odoo框架负责管理主事务的生命周期。不当的显式调用可能破坏事务的原子性,导致数据不一致或意外行为。savepoint 是在框架管理的事务内进行更细粒度控制的推荐方式。


2. 何时考虑绕过 ORM 执行原生 SQL?

尽管Odoo ORM功能强大,但在某些情况下,直接执行SQL可能是更合适或唯一的选择。

2.1 场景分析
  1. 极其复杂的报表查询: 当报表逻辑非常复杂,需要用到高级SQL特性(如窗口函数、公用表表达式(CTE)、复杂的JOIN和子查询组合)而这些特性难以或无法通过ORM的 search() domain 和 read_group() 高效表达时。
  2. 利用特定数据库功能: 如果需要使用PostgreSQL特有的数据类型、函数或扩展(如PostGIS进行地理空间查询,或全文搜索的高级配置),原生SQL提供了直接的访问途径。
  3. 超大规模的数据批量更新/删除: 对于涉及数百万条记录的批量更新或删除操作,ORM逐条或小批量处理可能因Python层开销和内存占用过大而效率低下。单条或少数几条优化的原生SQL UPDATEDELETE 语句通常会快得多。
    • 例如:UPDATE product_product SET active = FALSE WHEREcateg_id = X AND create_date < 'YYYY-MM-DD'
  4. 性能极致优化: 在某些经过严格分析和性能测试后,确认ORM生成的查询不是最优,且手写SQL能带来显著性能提升的关键路径上。
  5. 与不支持ORM的数据库表交互: 虽然罕见,但在集成遗留系统或操作非Odoo管理的表时可能需要。
  6. 数据迁移或修复: 在复杂的数据迁移脚本或一次性的数据修复任务中,直接SQL可以提供更大的灵活性和控制力。
2.2 绕过ORM的固有风险与缺点

在决定使用原生SQL之前,必须清醒地认识到其潜在的弊端:

  1. 破坏模型业务逻辑:
    • Python层逻辑被绕过: 直接SQL操作不会触发模型中定义的Python方法,如 create(), write(), unlink() 的重载逻辑。
    • 计算字段不更新: 依赖于被修改字段的计算字段 (@api.depends) 不会自动重新计算和存储。
    • 约束不执行: Python级别的数据约束 (@api.constrains) 不会被检查。
    • onchange 无效: @api.onchange 方法不会被触发。
    • 工作流/自动化规则: 基于ORM事件触发的自动化规则或工作流可能不会执行。
  2. ORM缓存问题: ORM为提高性能,会在内存中缓存记录数据。直接SQL修改数据库后,ORM缓存中的数据可能变为脏数据(过时数据),导致后续ORM操作读取到错误信息,除非手动处理缓存。
  3. 数据库无关性降低: ORM致力于提供一定程度的数据库无关性(尽管Odoo主要针对PostgreSQL)。原生SQL通常会使用特定数据库的方言和特性,降低了代码的可移植性。
  4. 安全性风险:
    • SQL注入: 如果SQL语句是动态拼接字符串而成,且包含了未经妥善处理的用户输入或外部数据,极易产生SQL注入漏洞(详见下文)。
    • 访问权限控制被绕过: 原生SQL查询通常会绕过Odoo的访问权限控制(ACLs)和记录规则(Record Rules),可能导致越权数据访问或修改。
  5. 维护困难: 原生SQL嵌入在Python代码中,不如ORM代码易读和易维护。数据库模式的变更可能需要手动更新所有相关的原生SQL查询。
  6. 升级问题: Odoo版本升级时,如果数据库结构发生变化,原生SQL可能失效或产生错误,需要手动适配。

结论:原生SQL应作为最后的手段,在充分理解其影响并评估风险后谨慎使用。


3. 安全地执行原生 SQL 查询

当确实需要执行原生SQL时,必须遵循安全和标准的实践。

3.1 核心原则:防范 SQL 注入 (SQL Injection) – 首要安全考量

SQL注入是Web应用中最常见也最具破坏性的安全漏洞之一。 它发生在当应用程序将不可信的数据(通常来自用户输入)直接拼接到SQL查询字符串中,从而允许攻击者操纵SQL查询的结构,执行任意数据库命令。

  • 什么是SQL注入?

假设你有一个查询,用于根据用户提供的名称查找产品:

# 极度危险的反模式 - 不要这样做!
user_supplied_name = "My Product'; DROP TABLE product_product; --" 
query_string = "SELECT * FROM product_product WHERE name = '" + user_supplied_name + "'"
# self.env.cr.execute(query_string) # 如果这样执行,后果不堪设想!
# 最终的SQL可能变成:
# SELECT * FROM product_product WHERE name = 'My Product'; DROP TABLE product_product; --'

在这个例子中,攻击者通过输入恶意构造的名称,成功地在原始查询后注入了一个 DROP TABLE 命令。

  • 绝对禁止字符串拼接或格式化构造包含外部数据的SQL!

使用 + 拼接字符串,或使用Python的 % 操作符、str.format()f-strings 将变量直接插入SQL查询主体,都是极不安全的。

  • 正确方法:参数化查询 (Parameterized Queries)

参数化查询(也称预备语句,Prepared Statements)是将SQL查询的结构与查询中的变量数据分离开来的机制。数据库驱动程序负责安全地将数据值代入查询,有效防止SQL注入。

3.2 使用 self.env.cr.execute(query, params=None)

Odoo通过其数据库游标 self.env.cr(一个 psycopg2 游标的封装)提供了执行参数化查询的标准方法。

  • query (str): SQL查询语句字符串。变量部分使用占位符,对于psycopg2(Odoo使用的PostgreSQL驱动),占位符是 %s
  • params (tuple or dict, optional): 包含要传递给查询的值。
    • 如果 query 中使用 %s 作为占位符,params 应该是一个元组 (tuple),其元素顺序与 %s 占位符在查询中出现的顺序一致。
    • 如果使用命名占位符(如 %(name)s),params 应该是一个字典 (dict)
  • 代码示例 (参数化查询):
# 安全的查询,使用 %s 占位符和元组参数
supplier_name_pattern = 'Azure Interior%'
min_stock = 10

query_select = """
    SELECT id, name, qty_available
    FROM product_template
    WHERE name ilike %s 
      AND type = %s
      AND qty_available > %s
    ORDER BY name;
"""
params_select = (supplier_name_pattern, 'product', min_stock)
self.env.cr.execute(query_select, params_select)

# 结果处理 (见下节)
products = self.env.cr.dictfetchall()
for product in products:
    _logger.info(f"产品: {product['name']}, 库存: {product['qty_available']}")

# 如果使用命名占位符 (更易读,但%s更通用)
# query_named = "SELECT * FROM res_partner WHERE name = %(partner_name)s AND company_id = %(cid)s;"
# params_named = {'partner_name': 'Deco Addict', 'cid': self.env.company.id}
# self.env.cr.execute(query_named, params_named)

psycopg2 驱动程序会负责正确地转义 params 中的值,使其作为数据安全地插入到查询中,而不是作为SQL代码的一部分被执行。

3.3 获取查询结果

在执行 SELECT 语句后,可以使用游标的以下方法获取结果:

  • self.env.cr.fetchall(): 获取所有查询结果行,返回一个元组列表 (list of tuples)。每条记录是一个元组。
self.env.cr.execute("SELECT name, email FROM res_partner WHERE active = %s LIMIT %s", (True, 2))
all_rows_tuples = self.env.cr.fetchall()
# [('Partner A', 'a@example.com'), ('Partner B', 'b@example.com')]
for row_tuple in all_rows_tuples:
    _logger.info(f"Name: {row_tuple[0]}, Email: {row_tuple[1]}")
  • self.env.cr.fetchone(): 获取查询结果的第一行,返回一个元组 (tuple),如果无结果则返回 None
self.env.cr.execute("SELECT id FROM res_users WHERE login = %s", (self.env.user.login,))
user_row_tuple = self.env.cr.fetchone()
if user_row_tuple:
    user_id = user_row_tuple[0]
    _logger.info(f"当前用户ID: {user_id}")
  • self.env.cr.dictfetchall(): 获取所有查询结果行,返回一个字典列表 (list of dictionaries)。每条记录是一个字典,键是列名。
self.env.cr.execute("SELECT id, name, amount_total FROM sale_order WHERE partner_id = %s", (partner_id,))
all_rows_dicts = self.env.cr.dictfetchall()
# [{'id': 1, 'name': 'SO001', 'amount_total': 100.0}, {'id': 2, 'name': 'SO002', 'amount_total': 200.0}]
for row_dict in all_rows_dicts:
    _logger.info(f"Order: {row_dict['name']}, Total: {row_dict['amount_total']}")
  • self.env.cr.dictfetchone(): 获取查询结果的第一行,返回一个字典 (dict),如果无结果则返回 None

选择哪种获取方法取决于你的需求和偏好。dictfetchall()dictfetchone() 通常更易读,因为可以通过列名访问数据。

3.4 执行数据修改语句 (DML: INSERT, UPDATE, DELETE)

执行 INSERT, UPDATE, DELETE 等数据修改语句时,方法与 SELECT 类似,也是使用 self.env.cr.execute(query, params)

# 安全的 UPDATE 语句
product_id_to_update = 15
new_standard_price = 99.99
query_update = "UPDATE product_template SET standard_price = %s WHERE id = %s AND active = %s;"
params_update = (new_standard_price, product_id_to_update, True)
self.env.cr.execute(query_update, params_update)
_logger.info(f"更新了 {self.env.cr.rowcount} 条产品模板记录的价格。") # cr.rowcount 返回受影响的行数

# 安全的 INSERT 语句
new_partner_name = "SQL Inserted Partner"
new_partner_email = "sql@example.com"
query_insert = "INSERT INTO res_partner (name, email, company_id, active) VALUES (%s, %s, %s, %s) RETURNING id;"
params_insert = (new_partner_name, new_partner_email, self.env.company.id, True)
self.env.cr.execute(query_insert, params_insert)
inserted_id = self.env.cr.fetchone()[0] # 获取 RETURNING 子句返回的ID
_logger.info(f"插入了新的伙伴记录,ID: {inserted_id},影响行数: {self.env.cr.rowcount}")

注意: 执行DML语句后,数据更改会成为当前Odoo事务的一部分。如果后续ORM操作或Python代码抛出异常,这些SQL更改也会被回滚(除非你显式处理了 commit,但这极不推荐)。


4. 原生 SQL 操作后的必要步骤

当通过原生SQL直接修改了数据库中的数据后,Odoo的ORM层可能对此一无所知,这会导致一些副作用,需要手动处理。

4.1 手动清除 ORM 缓存: self.invalidate_cache()
  • 原因: Odoo ORM为了提升性能,会在内存中缓存已读取的记录数据和部分查询结果。如果你用原生SQL直接修改了数据库表,ORM缓存中对应的数据就变成了脏数据 (stale data)。后续通过ORM读取这些记录时,可能会得到过时的、不正确的数据。
  • 解决方法: 在执行了直接修改数据库的原生SQL后,必须手动使相关的ORM缓存失效
# 假设我们用原生SQL更新了ID为 partner_id 的伙伴信息
# ... 执行 self.env.cr.execute("UPDATE res_partner SET phone = %s WHERE id = %s", (new_phone, partner_id)) ...

# 使特定记录的缓存失效
partner_record = self.env['res.partner'].browse(partner_id)
partner_record.invalidate_cache() 
# 或者,如果知道哪些字段被修改了,可以更精确:
# partner_record.invalidate_cache(fnames=['phone'])

# 如果修改了多条记录,可以在记录集上调用
# updated_partners = self.env['res.partner'].browse(list_of_updated_ids)
# updated_partners.invalidate_cache()

# 如果对整个模型进行了广泛的未知修改,或不确定具体哪些记录受影响
# (应尽量避免这种情况,因为它会清空该模型所有已加载记录的缓存):
# self.env['res.partner'].invalidate_cache()
  • 何时调用: 紧跟在执行修改数据的原生SQL之后,并且在任何可能读取这些被修改数据的ORM操作之前。
  • 范围: invalidate_cache() 可以不带参数(清空记录集内所有字段的缓存),或带 fnames (一个字段名列表,只清空这些字段的缓存),或带 ids (一个ID列表,只清空这些ID对应记录的缓存)。
4.2 手动触发依赖计算 (如果适用,高级)

如果原生SQL修改的字段是其他计算字段 (@api.depends, store=True) 的依赖项,这些计算字段不会自动重新计算。你可能需要:

  1. 在原生SQL之后,通过ORM的 write() 方法“触摸”这些依赖字段或计算字段本身(例如写入其当前值),以触发重算机制。
  2. 或者,如果计算逻辑可以在SQL层面复制,考虑在原生SQL中一并更新这些依赖的计算字段(但这可能很复杂且容易出错)。

这通常是一个复杂的问题,应尽可能避免通过原生SQL修改这类字段。

4.3 考虑对权限的影响

原生SQL查询通常以数据库连接用户的权限执行(在Odoo中通常是odoo数据库用户),这会绕过Odoo应用层面设置的访问控制列表(ACLs)和记录规则。这意味着:

  • 使用原生SQL SELECT 可能读取到当前Odoo用户无权查看的数据。
  • 使用原生SQL UPDATE, INSERT, DELETE 可能修改或删除当前Odoo用户无权操作的数据。

在编写原生SQL时,必须自行负责权限的检查和控制,或者确保执行上下文是可信的(例如,在仅由管理员触发的、经过严格审查的服务器端脚本中)。


5. 总结与最佳实践

  1. ORM优先: 始终优先使用Odoo ORM进行数据操作。它提供了安全性、数据一致性保障、数据库无关性、缓存以及与框架其他部分的良好集成。
  2. 原生SQL是最后手段: 仅在ORM无法满足需求(极端性能、复杂查询、特定DB功能)且已充分评估风险后,才考虑使用原生SQL。
  3. 参数化查询是铁律: 永远不要通过字符串拼接或格式化来构造包含外部数据的SQL查询。始终使用 self.env.cr.execute(query, params) 并传入参数元组/字典来防范SQL注入。
  4. 理解事务: 利用Odoo的自动事务管理。使用 with self.env.cr.savepoint(): 进行细粒度的部分回滚,避免直接调用 cr.commit()cr.rollback()
  5. 处理缓存: 执行修改数据的原生SQL后,必须调用 self.invalidate_cache() 清除受影响记录的ORM缓存。
  6. 注意副作用: 意识到原生SQL会绕过模型的业务逻辑(Python方法、计算字段、约束等)和访问权限。
  7. 明确日志与测试: 对使用原生SQL的代码进行详细的日志记录,并进行彻底的测试,包括安全测试和边界条件测试。
  8. 代码审查: 包含原生SQL的代码块应接受更严格的代码审查,确保其安全性和必要性。

通过遵循这些指南,开发者可以在必要时利用原生SQL的强大功能,同时最大限度地降低风险,维护Odoo应用的安全性和稳定性。


八、ORM 开发代码规范

引言: Odoo的ORM是其强大功能的核心。遵循这些圣约,你将能构建出健壮、高效、可维护且安全的Odoo应用。视之为团队的共同契约,代码审查的基石。


I. 编码风格与命名约定 (Coding Style & Naming Conventions)

目标:清晰、一致、易读。

  1. 模型名称 (Model Names - _name):
    • 使用点号分隔的小写单词:module_name.business_object (例如: sale.order, account.move.line)。
    • 保持业务对象的单数形式。
  2. 字段名称 (Field Names):
    • 小写蛇形命名法 (snake_case): field_name, amount_total, partner_shipping_id
    • Many2one 字段以 _id 结尾: partner_id, user_id, company_id
    • One2manyMany2many 字段以 _ids 结尾: order_line_ids, tag_ids
    • related 字段名应清晰反映其关联路径,但本身遵循蛇形命名法。
  3. 方法名称 (Method Names):
    • 小写蛇形命名法。
    • 私有方法 以单下划线 _ 开头: _compute_total_amount, _get_default_stage_id
    • 计算方法 (@api.depends): 惯例以 _compute_ 开头,例如 _compute_display_name
    • Onchange 方法 (@api.onchange): 惯例以 _onchange_ 开头,例如 _onchange_product_id
    • 约束方法 (@api.constrains): 惯例以 _check_ 开头,例如 _check_date_consistency
    • Action 方法 (按钮调用等): 通常以 action_ 开头,例如 action_confirm, action_view_invoice
    • Inverse 方法: 惯例以 _inverse_ 开头,对应其计算字段。
    • Search 方法: 惯例以 _search_ 开头,对应其计算字段的搜索实现。
  4. 变量名称 (Variable Names):
    • 小写蛇形命名法: order_lines, total_amount
    • 记录集变量名通常用复数形式或加 _rs 后缀:partnerspartner_rs
  5. 可读性:
    • 遵循 PEP 8 Python代码风格指南。
    • 合理使用空行分隔逻辑块。
    • 注释应简洁明了,解释“为什么”而非“做什么”(代码本身应清晰说明“做什么”)。

II. ORM 最佳实践金科玉律 (Golden Rules of ORM Best Practices)

核心:性能、数据完整性、可维护性。

  1. @api.depends 必须详尽:
    • 准则: 计算字段的所有依赖项都必须在 @api.depends() 中明确声明,包括通过关联字段访问的属性 (e.g., 'partner_id.country_id.code')。
    • 理由: 确保ORM在依赖项变更时能正确触发重算,避免数据不一致和难以追踪的BUG。
  2. @api.onchange 仅用于UI交互:
    • 准则: onchange 方法中严禁执行任何数据库写操作 (create, write, unlink) 或调用可能触发写操作的业务方法。其主要职责是修改当前记录在视图中的其他字段值、设置 domain 或返回 warning
    • 理由: onchange 在用户与表单交互时触发,此时记录可能尚未保存。执行写操作会产生非预期副作用、数据不一致,并可能破坏事务原子性。
  3. 封装复杂业务逻辑:
    • 准则: 避免在 create(), write() 方法中堆积大量复杂业务逻辑。应将其封装到独立的、命名良好的私有或公有方法中。
    • 理由: 提高代码的可读性、可维护性和可测试性。createwrite 应保持简洁,专注于数据持久化和核心ORM行为。
  4. 善用 _sql_constraints:
    • 准则: 对于可以通过数据库保证的数据完整性约束(如字段唯一性、组合唯一性、CHECK约束),优先使用模型的 _sql_constraints 属性。
    • 示例: _sql_constraints = [('name_uniq', 'unique (name, company_id)', 'The name must be unique per company!')]
    • 理由: 数据库层面的约束通常比Python层面的 @api.constrains 更高效,且是数据完整性的最后防线。
  5. 优先使用ORM的批量操作:
    • 准则: 利用ORM的批量能力,例如 create() 传入值列表,write()unlink() 直接在记录集上操作。
    • 理由: 显著减少数据库交互次数,大幅提升性能(见陷阱部分)。
  6. search() Domain 精准化:
    • 准则: 构建尽可能精确的 domain 条件,在数据库层面过滤数据,而不是捞出大量数据到Python层再用 filtered()
    • 理由: 数据库查询优化器远比Python循环高效。
  7. 使用 search_read() 获取原始数据:
    • 准则: 当只需要特定字段的原始数据(尤其是大量记录)且不需要ORM记录对象时,使用 search_read()
    • 理由: 避免创建大量ORM记录对象的开销,直接返回字典列表,性能更优。
  8. read_group() 用于数据聚合:
    • 准则: 对于分组聚合类查询(如按客户统计订单总额),使用 read_group()
    • 理由: 将聚合运算下推到数据库执行,效率远高于Python层面处理。
  9. 明智使用 sudo():
    • 准则: 仅在绝对必要且充分理解其安全影响时才使用 sudo()。优先考虑修复访问权限或使用 with_user() / with_company()
    • 理由: sudo() 绕过所有权限检查,滥用会导致严重安全漏洞。
  10. 上下文 (context) 的正确使用:
    • 准则: 理解并善用上下文。使用 self.with_context(...) 传递信息,避免滥用全局变量或不当修改已有上下文。
    • 理由: 上下文是Odoo中传递状态和参数的重要机制,正确使用能让代码更灵活和模块化。

III. 常见陷阱与禁忌 (Common Pitfalls & Prohibitions)

核心:规避性能瓶颈、数据错误和安全漏洞。

  1. 陷阱:循环中执行 search() / create() / write() / unlink() (N+1 问题)
    • 危害: 每次循环都可能触发数据库查询/写入,导致大量数据库交互,性能急剧下降。
    • 反模式 (Bad Practice):
# for partner_name in list_of_names:
#     partner = self.env['res.partner'].search([('name', '=', partner_name)], limit=1)
#     if partner:
#         partner.write({'comment': 'Processed'})

# for vals in data_to_create:
#     self.env['my.model'].create(vals)
    • 正确做法 (Good Practice):
# names_to_search = [name for name in list_of_names]
# partners_to_update = self.env['res.partner'].search([('name', 'in', names_to_search)])
# partners_to_update.write({'comment': 'Processed'}) # 批量写

# self.env['my.model'].create(data_to_create) # 批量创建
  1. 陷阱:计算字段中执行资源密集型操作 (尤其是循环内)
    • 危害: 计算字段(特别是 store=True 的)在依赖变更时触发,或非存储字段在每次读取时触发。若其内部包含复杂查询、大量循环或外部API调用,会导致严重性能问题。
    • 反模式 (Bad Practice):
# total_related_invoices = fields.Integer(compute='_compute_total_related_invoices')
# @api.depends('partner_id') # 依赖不全或过于宽泛也可能导致问题
# def _compute_total_related_invoices(self):
#     for record in self:
#         # 每次都执行一次search_count,如果记录多,开销巨大
#         record.total_related_invoices = self.env['account.move'].search_count([
#             ('partner_id', '=', record.partner_id.id), ('move_type', '=', 'out_invoice')
#         ])
    • 正确做法 (Good Practice):
      • 优化查询: 若必须查询,考虑使用 read_group 或更高效的聚合方式。
      • 重新设计: 思考是否真的需要此计算字段,或能否通过其他方式实现(如按钮动作、统计报表)。
      • 更精确的依赖: 确保 @api.depends 精确。
      • 异步处理: 对于耗时操作,考虑队列任务。
# (更好的方案可能是用One2many + related字段,或在需要时通过方法获取)
# 如果确实要这样计算,且数据量大,可能需要考虑存储并优化更新时机,
# 或者根本不适合做计算字段,而是一个专门的统计方法。
# 示例:如果需要按 partner 统计,read_group 更佳
# invoice_data = self.env['account.move'].read_group(
#    [('partner_id', 'in', self.mapped('partner_id').ids), ('move_type', '=', 'out_invoice')],
#    ['partner_id'], ['partner_id']
# ) # 然后将结果映射回记录
  1. 陷阱:滥用 sudo() 提升权限
    • 危害: sudo() 以超级用户权限执行代码,完全绕过访问控制,极易引入安全漏洞。
    • 反模式 (Bad Practice):
# def some_user_action(self):
#     # 用户无权写入某个字段,开发者图方便直接 sudo()
#     self.sudo().write({'confidential_field': 'some_value'})
    • 正确做法 (Good Practice):
      • 优先修复权限: 检查并修正用户的访问权限 (ACLs, 记录规则)。
      • 最小权限原则: 如果必须提升权限,使用 sudo(user_id_or_True/False) 精确控制提权用户,或 with_user(user_id) / with_company(company_id) 在特定上下文执行。
      • 封装逻辑: 将需要提权的操作封装在受控的、有明确安全审查的方法中。
# def some_user_action(self):
#     # 假设某后台用户(如计划任务用户)才有权写
#     system_user = self.env.ref('base.user_root') # 或者特定的服务用户
#     self.with_user(system_user).write({'confidential_field': 'some_value'})
  1. 陷阱:直接修改Odoo核心/社区模块代码 (未使用 _inherit)
    • 危害: 导致更新困难、代码冲突、功能不可预测,破坏模块化和可维护性。
    • 反模式 (Bad Practice):
      • 直接编辑安装在 addons/ 目录下的Odoo官方模块或第三方模块的 .py.xml 文件。
    • 正确做法 (Good Practice):
      • 始终创建自定义模块并通过 _inherit 机制扩展现有模型/视图。
# In your_custom_module/models/sale_order.py
from odoo import models, fields

class SaleOrder(models.Model):
    _inherit = 'sale.order' # 继承现有模型

    custom_field = fields.Char(string="My Custom Field")

    def action_confirm(self):
        # 调用父类方法,并添加自定义逻辑
        res = super(SaleOrder, self).action_confirm()
        self.custom_field = "Order Confirmed by Custom Logic"
        return res
  1. 陷阱:忽视 ondelete='cascade' 的性能和数据影响
    • 危害: 在Many2one字段上设置ondelete='cascade',当删除父记录时,会级联删除所有关联的子记录。如果关联记录非常多,或者存在深层级联,可能导致长时间的数据库锁和严重性能问题,甚至意外删除大量数据。
    • 反模式 (Bad Practice):
# class ProductCategory(models.Model):
#     _name = 'product.category'
#     # ...
#
# class ProductTemplate(models.Model):
#     _name = 'product.template'
#     # 如果一个分类下有成千上万产品,删除分类会导致灾难
#     categ_id = fields.Many2one('product.category', ondelete='cascade')
    • 正确做法 (Good Practice):
      • 审慎选择: 优先考虑 ondelete='restrict' (阻止删除,如果存在引用) 或 ondelete='set null' (将外键置空)。
      • 评估影响: 如果确实需要级联删除,确保理解其业务含义,并评估潜在的数据量和性能影响。在指向高频变动或大量数据的模型时尤其要小心。
      • 替代方案: 考虑使用归档 (active = False) 机制,或通过计划任务进行标记和延时清理。
class ProductTemplate(models.Model):
    _name = 'product.template'
    # 通常产品分类不应级联删除产品,而是阻止删除或置空
    categ_id = fields.Many2one('product.category', ondelete='restrict')

IV. 核心准则 (Core Tenets)

铭记于心,指导你的每一次编码决策。

  1. 安全性 (Security) 第一: 绝不信任外部输入。严防SQL注入。审慎使用sudo()。正确配置访问权限。
  2. 性能 (Performance) 导向: 避免N+1。批量操作。数据库端过滤聚合。理解ORM缓存与预取。
  3. 可维护性 (Maintainability) 至上: 代码清晰。逻辑封装。遵循约定。合理注释。使用继承扩展。

结语:

这份“代码圣经”并非一成不变的教条,而应是你持续学习和实践过程中的良师益友。优秀的Odoo开发者不仅掌握技术,更理解其背后的设计哲学。祝你在Odoo的世界中游刃有余,匠心构建!

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值