第七章 Odoo 12开发之记录集 – 使用模型数据
本文为最好用的免费ERP系统Odoo 12开发手册系列文章第七篇。
在上一篇文章中,我们概览了模型创建以及如何从模型中载入和导出数据。现在我们已有数据模型和相关数据,是时候学习如何编程与其进行交互 了。模型的 ORM(Object-Relational Mapping)提供了一些交互数据的方法,称为 API(Application Programming Interface)。这包括基本的增删改查(CRUD)操作,也包括一些其它操作,如数据导入导出,以及改善用户界面和体验的工具方法。它还包含一些我们在前面文章中所看到的装饰器。这些都让我们可以通过添加新的方法来调用 ORM 进行相关操作。
本文主要内容有:
- 使用 shell 命令交互式地学习 ORM API
- 理解执行环境和上下文
- 使用记录集和作用域(domain)查询数据
- 在记录集中访问数据
- 在记录中写入
- 编写记录集
- 使用底层 SQL 和数据库事务
开发准备
本文代码使用交互式 shell 命令行执行,无需使用前面章节的代码。
使用 shell 命令行
Python带有命令行界面,是研究其语法一个很好的方式。Odoo 也有类似的功能,可以交互式的测试命令的执行效果,这就是 shell 命令行。在命令行中执行以下命令并指定数据库即可使用:
1 | ~/odoo-dev/odoo/odoo-bin shell -d dev12 |
此时在终端上可以看到正常的服务启动信息,等到出现>>>Python提示符时即为完成,可以输入命令了。
ℹ️Odoo 9中的修改
shell 功能在9.0中才添加。Odoo 8.0可使用社区模块来添加这一功能。只需下载并放入 addons 路径即可使用,下载请见应用市场。
此处 self 表示管理员用户的记录,可通过如下命令进行确认:
1 | >>> self |
在以上 shell 会话中,我们检查了自己的环境:
- self命令表示res.users记录集,仅包含一条 id 为1的记录
- 查看self._name获得记录集模型名,你可能猜到了,是’res.users’
- 记录的 name 值为OdooBot
- 记录的 login 字段值为__system__
ℹ️Odoo 12中的修改
id 号为1的超级用户由原来的 admin 变成无法直接登录的内部系统用户。现在 admin 的 id 号为 2并且不是超级用户,但默认各应用会将其加入所有安全组。主要原因是避免用户使用超级用户账号来执行日常操作。这样的风险是该用户会跳过权限规则并导致数据的不一致,比如跨公司(cross-company)关联。现在超级用户仅用于检测问题或具体的跨公司操作。
和 Python 一样,可通过 Ctrl + D退出该命令行。此时会结束服务并返回到系统shell 命令行。
执行环境
Odoo shell 中包含一个 self 引用,类似于在res.users模型的方法中看到的那样。如我们所见,self 是一个记录集。记录集自带环境信息,包括浏览信息的用户以及其它上下文信息,如语言和时区。下面我们会学习执行环境中可用的属性、环境上下文的用处以及如何修改该上下文。
环境属性
我们可通过如下代码查看当前环境:
1 | >>> self.env |
self.env 中的执行环境中有以下属性:
- env.cr是正在使用的数据库游标(cursor)
- env.user是当前用户的记录
- env.uid是会话用户 id,与env.user.id相同
- env.context是会话上下文的不可变字典
环境还提供对带有所有已安装模型注册表的访问,如self.env[‘res.partner’]返回一条对 partner 模型的引用。然后我们还可以对其使用search()或browse()方法来获取记录集:
1 | >>> self.env['res.partner'].search([('name', 'like', 'Ad')]) |
上例中返回的res.partner模型记录集包含三条记录,id 分别为10, 35和3。记录集并没有按 id 排序,因为使用了相应模型的默认排序。就 partner 模型而言,默认的_order为display_name。
环境上下文
环境上下文是一个带有会话数据的字典,可用于客户端用户界面以及服务端 ORM 和业务逻辑中。在客户端中,它可以把信息从一个视图带到另一个视图中,比如前一个视图中活跃的记录 id,通过点击链接或按钮,可将默认值带入到下一个视图中。在服务端中,一些记录集的值会依赖于上下文提供的本地化设置。具体的例子有lang键影响可翻译字段的值。上下文还可为服务端代码提供信号。比如active_test键在设为 False 时,会改变ORM中search()方法的行为,它会忽略记录中的active标记,inactive(假删除)的记录也会被返回。
客户端的初始上下文长这样:
1 | {'lang': 'en_US', 'tz': 'Europe/Brussels', 'uid': 2} |
补充:服务端查看上下文命令为self.context_get()或self.env.context
其中 lang 键为用户语言,tz 为时区信息,uid 为当前用户 id。记录中的内容随当前依赖的上下文可能会不同:
- translated字段根据活跃的 lang 语言不同值也会不同
- datetimep字段根据活跃的的 tz 时区不同时间会不同
在上一个视图中点击链接或按钮打开表单时,一个active_id键会被加入上下文,它带有原表单我们所在位置记录的 id。以列表视图为例,active_ids上下文键中包含上一个列表中所选择的记录 id 列表。
在客户端中,上下文可用于使用default_或default_search_前缀在目录视图上设置默认值或启动默认过滤器。举例如下:
- 设置当前用户为user_id字段默认值,使用{‘default_user_id’: uid}
- 在目标视图上默认启动filter_my_books过滤器,使用{‘default_search_filter_my_tasks’: 1}
修改记录集执行环境
记录集执行环境是不可变的,因此不能被修改,但我们可以创建一个变更环境并使用它来执行操作。我们通过如下方法来实现:
- env.sudo(user)中传入一条用户记录并返回该用户的环境。如未传入用户,则使用__system__超级用户root,这时可绕过安全规则执行指定操作。
- env.with_context(
) 替换原上下文为新的上下文 - env.with_context(key=value,…)修改当前上下文,为一些键设置值
此外还有一个env.ref()函数,传入一个外部标识符字符串并返回它的记录,请参见:
1 | >>> self.env.ref('base.user_root') |
使用记录集和作用域(domain)查询数据
在方法或 shell 会话中,self表示当前模型,并且我们仅能访问该模型的记录。要访问其它模型就需要使用self.env。例如self.env[‘res.partner’]返回一条对 Partner 模型的引用(也是一个空记录集)。我们可以使用search()或browse()来获取记录集,其中search()方法使用域表达式来定义记录选择范围。
创建记录集
search()方法接收一个域表达式并返回符合条件记录的记录集。空域[] 将返回所有记录。
ℹ️如果模型有特殊字段 active,默认只有active=True的记录才在选择范围内
还可以使用以下关键字参数:
- order是一个数据库查询语句中ORDER BY使用的字符串,通常是一个逗号分隔的字段名列表。每个字段都可接DESC关键字,用于表示倒序排列。
- limit设置获取记录的最大条数
- offset忽略前 n 前记录,可配合limit使用来一次查询指定范围记录
有时我们只要知道满足某一条件的记录条数,这时可使用search_count()来返回记录条数而非记录集。这节约了先获取记录列表再记数的开销,在还没有获取记录集且仅想知道记录条数时这样会更高效。
browse()方法接收一个 ID 列表或单个ID并返回这些记录的记录集。在我们知道 ID 并想要获取记录时这就非常方便了。
一些使用示例如下:
1 | >>> self.env['res.partner'].search([('name', 'like', 'Pac')]) |
域表达式
域(domain)用于过滤数据记录。它使用一个特殊语法来供 Odoo ORM解析,生成数据库查询中的 WHERE 表达式。域表达式是一组条件组成的列表,每个条件都是一个 (‘字段名’, ‘运算符’, ‘值’) 组成的元组,例如,[(‘is_done’,’=’,False)]是仅带有一个条件的有效域表达式。以下是对各个元素的说明:
字段名:是一个待过滤字段,可使用点号标记来表示关联模型中的字段
值:在 Python 表达式中运行。可使用字面值,如数字、布尔值、字符串和列表,也可使用运行上下文中的字段和标识符。针对域其实有两种运行上下文:
- 在窗口操作或字段属性等客户端中使用时,可使用原生字段值来渲染当前可用视图,但不能对其使用点标记符
- 在服务端使用时,如安全记录规则或服务端 Python 代码中,可以对字段使用点标记符,因为当前记录是一个对象
运算符:可以是以下中的一个
- 常用比较运算符有<, >, <= , >=, =和!=。
- ‘=like’和’=ilike’匹配某一模式,这里下划线_匹配单个字符,百分号%匹配任意一组字符。
- ‘like’匹配’%value%’模式,’ilike’与其相似但忽略大小写。还可以使用’not like’和’not ilike’运算符。
- ‘child of’在配置支持层级关联的模型中查找层级关系中的子级值。
- ‘in’ 和’not in’用于查看给定列表的包含,所以其值为一个列表。用于to-many关联字段时,in运算符和contains运算符一样。
- ‘not in’是in的反向运算,用于查看不在列表中的值。
域表达式是一个列表并且包含多个条件元组。默认这些条件使用AND逻辑运算符连接,也就是说它仅返回满足所有条件的记录。也可以使用显式逻辑运算符 - ‘&’符号表示 AND 运算符(默认值),管道运算符’|’表示OR运算符。这两个运算符会作用于接下来的两项,递归执行。后面我们会一起来详细了解。
ℹ️域表达式使用了更为正式的定义方式:前缀标记法,也称波兰表达式(Polish notation):运算符放在运算项之前。AND和OR是二元运算符,而NOT是一元运算符。
感叹号’!’表示NOT运算符,可用于下一项的运算,因此要放执行的否定项之前。例如[‘!’, (‘is_done’,’=’,True)]将过滤出所有未完成(not-don e)的记录。
下一项本身也可以是一个作用其后续项的运算符,形成一个嵌套条件。下例可以有助于我们进行理解。在服务端记录规则中,可以找到类似下面这样的域表达式:
1 | ['|', |
这个域过滤出当前用户在follower列表中并且是负责人用户,或者没有负责人用户的用户集。第一个’|’或运算符作用于 follower 条件以及下一个条件的结果。下一个条件是后面两个条件的并集:用户ID是当前会话用户或未进行设置。下图是上例域表达式的抽象语法树表示:
在记录集中访问数据
一旦获取了数据集,就可以查看其中包含的数据了。下面的几个部分中我们就来看看如何访问记录集中的数据。我们可以获取单条记录的字段值,称为单例(singleton)。关联字段带有特殊属性,我们可通过点号标记来查看关联记录。最后我们一起思考处理日期和时间记录并进行格式转换。
访问记录中数据
记录集的一个特例是仅有一条记录,称为单例。单例仍是记录集,在需要记录集的地方均可使用。与多元素记录集不同,单例可使用点号标记访问它的字段,如:
1 | >>> print(self.name) |
下个例子中我们看看同一个 self 单例和记录集相同的行为,我们可对其进行遍历。它只有一条记录,所以只会打印出一个名称:
1 | >>> for rec in self: |
尝试访问有多条记录的记录集字段值会产生错误,所以在不确定操作的是否为单例数据集时就会产生问题。对于设计仅操作单例的方法,可在开头处使用self.ensure_one(),如果 self 不是单例时将抛出错误。
ℹ️空记录也是单例。这样很方便,因为访问字段会返回 None 而非抛出错误。对于关联字段同样如此,使用点号标记访问关联记录也不会抛出错误。
访问关联字段
如前面所见,模型可包含关联字段:many-to-one, one-to-many和many-to-many。这些字段类型的值为记录集。
对于many-to-one,其值可以是单例或空记录集。两种情况下都可以直接访问字段值。如下例中的命令是正确并安全的:
1 | >>> self.company_id |
为避免麻烦,空记录可像单例一样操作,访问其字段值不会返回错误而是返回 False。所以我们可以使用点号标记来遍历字段,而无需担心因其值为空而报错,如:
1 | >>> self.company_id.parent_id |
访问时间和日期值
在记录集中,日期和日期时间值以原生 Python 对象展示,例如,在查询上次 admin 用户登录日期时:
1 | >>> self.browse(2).login_date |
因为日期和日期时间是 Python 对象,它们可使用这些对象的所有功能。
ℹ️Odoo 12中的修改
date和datetime字段值以 Python 对象表示,而此前 Odoo 版本中它们以文本字符串表示。这些字段类型值仍可像此前 Odoo 版本中那样使用文本表示。
日期和时间在数据库中以原生的世界标准时间(UTC) 格式存储,不受时区影响。 在记录集中看到的datetime值也是 UTC格式,在客户端中向用户展示时,datetime值会根据当前会话的时间设置来转换成用户的时区。这一设置存储在上下文的tz键中,如{‘tz’: ‘Europe/Brussels’}。这一转换由客户端负责,而不是由服务端完成。
例如在布鲁塞尔(UTC+1)的用户输入12:00 AM数据库中会存储为10:00 AM UTC,而在纽约(UTC-4) 的用户查看时则为06:00 AM。
补充:请不要怀疑作者的数学是不是体育老师教的😂,布鲁塞尔为东一区,纽约为西五区,但冬令时和夏令时让这个问题变复杂了。将12:00修改为11:00应该就正确了。
ℹ️Odoo 服务日志消息时间戳使用UTC时间而非本地服务器时间
相反的转换,由会话时区转换为UTC,也需由客户端在将用户输入的datetime传回服务器时完成。日期对象可进行比较和相减来获取两个日期的时间差,时间差是一个timedelta对象。timedelta可通过date运算对date和datetime对象进行加减。这些对象由 Python 标准库datetime模块提供,以下是使用它进行的基本运算示例:
1 | >>> from datetime import date |
对于date, datetime和timedelta数据类型的完整参考请见Python 官方文档。Odoo 还在odoo.tools.date_utils模块中提供了一些额外的便利函数,这些函数有:
- start_of(value, granularity)是某个特定刻度时间区间的开始时间,这些刻度有year, quarter, month, week, day或hour
- end_of(value, granularity)是某个特定刻度时间区间的结束时间
- add(value, kwargs)为指定值加上一个时间间隔。kwargs参数由一个relativedelta对象来定义时间间隔。这些参数可以是years, months, weeks, days, hours, minutes等等
- subtract(value, **kwargs)为指定值减去一个时间间隔
relativedelta对象来自dateutil库,可使用months或years执行date运算(Python的timedelta标准库仅支持days)。更多内容请见相关文档。以下为上述函数的一些使用示例:
1 | >>> from odoo.tools import date_utils |
这些工具方法在odoo.fields.Date和the odoo.fields.Datetime对象中也可使用,如:
- fields.Date.today()返回服务器所需格式的当前日期,它使用UTC作为一个引用。这足以计算默认值,这种情况下只需使用函数名无需添加括号。
- fields.Datetime.now() 返回服务器所需格式的当前datetime,它使用UTC作为一个引用。这足以计算默认值,
- fields.Date.context_today(record, timestamp=None)在会话上下文中返回带有当前日期的字符串。时间从记录上下文中获取。可选项timestamp参数是一个datetime对象,如果传入将不使用当前时间,而使用传入值。
- fields.Datetime.context_timestamp(record, timestamp)将原生的datetime值(无时区)转换为具体时区的datetime。时区从记录上下文中提取,因此使了前述函数名。
转换文本形式的日期和时间
在Odoo 12以前,在进行运算前我们需要对文本形式的date和datetime进行转换。有些工作可帮助我们完成文本和原生数据类型的相互转换。这在此前的 Odoo 版本中都非常有用并且在 Odoo 12中也仍然相关:我们要将给到的日期格式化为文本。为便于格式之间的转换,fields.Date和fields.Datetime都提供了如下函数:
- to_date将字符串转换为date对象
- to_datetime(value)将字符串转换为datetime对象
- to_string(value)将date或datetime对象转换为 Odoo 11及之前版本Odoo服务所需的字符串格式
函数所需的文本格式由 Odoo 通过如下方式默认预置:
- odoo.tools.DEFAULT_SERVER_DATE_FORMAT
- odoo.tools.DEFAULT_SERVER_DATETIME_FORMAT
它们分别与%Y-%m-%d和%Y-%m-%d %H:%M:%S相对应。from_string用法示例如下:
1 | >>> from odoo import fields |
对于其它的日期和时间格式,可使用datetime对象中的strptime方法:
1 | >>> from datetime import datetime |
在记录中写入
有两种写入记录的方式:使用对象形式直接分配和使用write() 方法。第一种很简单但一次只能操作一条记录,效率较低。因为每次分配都执行一次写操作,会产生冗余的重复计算。第二种要求写入关联字段时使用特殊语法,但每条命令可写入多个字段和记录,记录计算更为高效。
使用对象形式分配值写入
记录集实施活跃记录模式。也就是说我们可以为其分配值,并且会将这些修改在数据库中持久化存储。这是一种操作数据的易于理解和便捷的方式,但一次只能操作一个字段和一条记录。如:
1 | >>> root = self.env['res.users'].browse(1) |
虽然使用的是活跃记录模式,也可以通过分配记录值来设置关联字段。对于many-to-one字段,分配的值必须是单条记录(单例)。对于to-many字段,也可以通过一条记录集分配,来替换关联记录列表为新列表(如果有的话),这里允许任何大小的记录集。
通过 write()方法写入
我们还可以使用write()方法来同时更新多条记录中的多个字段,仅需一条数据库命令。所以在重视效果时就应优先考虑这一方式。write() 接收一个字典来进行字段和值的映射。这会更新记录集中的所有记录并且没有返回值,如:
1 | >>> Partner = self.env['res.partner'] |
与对象形式的分配不同,使用write() 方法时我们不能直接为关联字段分配记录集对象。取而代之的是,我们需要使用所需的记录ID来从记录集中进行提取。在写入many-to-one字段时,写入的值必须是关联记录的ID。例如,我们不用self.write({‘user_id’: self.env.user}),而应使用self.write({‘user_id’: self.env.user.id})。
在写入to-many字段时,写入的值必须使用和 XML 数据文件相同的特殊语法,这在第五章 Odoo 12开发之导入、导出以及模块数据中有介绍。比如,我们设置图书作者列表为author1和author2,这是两条 Partner 记录。| 管道运算符可拼接记录来创建一个记录集,因此使用对象形式的分配可以这么写:
1 | publisher.child_ids = author1 | author2 |
使用write()方法,同样的操作如下:
1 | book.write( { 'child_ids': [(6, 0, [author1.id, author2.id])] } ) |
回顾第五章 Odoo 12开发之导入、导出以及模块数据的写入语法,最常用的命令如下:
- (4, id, _)添加一条记录
- (6, _, [ids])替换关联记录列表为所传入的列表
写入日期和时间值
从 Odoo 12开始,不论是直接分配还是使用 write()方法,日期和时间字段都可以 Python 原生数据类型写入。我们仍可以使用文本形式值写入日期和时间:
1 | >>> demo = self.search([('login', '=', 'demo')]) |
创建和删除记录
write()方法用于向已有记录写入日期,但我们还需要创建和删除记录。这通过create()和unlink()模型方法实现。create()接收所需创建记录字段和值组成的字典,语法与 write()一致。没错,默认值会被自动应用,如下所示:
1 | >>> Partner = self.env['res.partner'] |
unlink()方法会删除记录集中的记录,如下所示:
1 | >>> rec = Partner.search([('name', '=', 'ACME')]) |
以上我们看到日志中几条其它记录被删除的消息,这些是所删除 partner 关联字段的串联删除。
还有copy()模型方法可用于复制已有记录,它接收一个可选参数来在新记录中修改值,如复制demo 用户创建一个新用户:
1 | >>> demo = self.env.ref('base.user_demo') |
带有copy=False属性的字段不会被自动拷贝。to-many关联字段带有该标记时默认被禁用,因此也不可拷贝。
重构记录集
记录集还支持一些其它运算。我们可查看一条记录是否在记录集中。如果x是一个单例,并且my_recordset是一个包含多条记录的记录集,可使用如下代码:
- x in my_recordset
- x not in my_recordset
还能使用如下运算:
- recordset.ids 返回记录集元素的ID列表
- recordset.ensure_one()检查是否为单条记录(单例);若不是,则抛出ValueError异常
- recordset.filtered(func)返回一个过滤了的记录集,func可以是一个函数或一个点号分隔的表达式来表示字段路径,可参见下面的示例。
- recordset.mapped(func)返回一个映射值列表。除函数外,还可使用文本字符串作为映射的字段名。
- recordset.sorted(func)返回一个排好序的记录值。除函数外,文本字符串可用作排序的字段名。reverse=True是其可选参数。
以下是这些函数的使用示例:
1 | >>> rs0 = self.env['res.partner'].search([]) |
我们势必会对这些关联字段中的元素进行添加、删除或替换的操作,那么就带来了一个问题:如何操作这些记录集呢?
记录集是不可变的,也就是说不能直接修改其值。那么修改记录集就意味着在原有的基础上创建一个新的记录集。一种方式是使用所支持的集合运算:
- rs1 | rs2是一个集合的并运算,会生成一个包含两个记录集所有元素的记录集
- rs1 + rs2是集合加法运算,会将两个记录集拼接为一个记录集,这可能会带来集合中有重复记录
- rs1 & rs2是集合的交集运算,会生成一个仅在两个记录集中同时出现元素组成的数据集
- rs1 - rs2是集合的差集运算,会生成在rs1中有但rs2中没有的元素组成的数据集
还可以使用分片标记,例如:
- rs[0]和rs[-1]分别返回第一个和最后一个元素
- rs[1:]返回除第一元素外的记录集拷贝。其结果和rs - rs[0]相同,但保留了排序
ℹ️Odoo 10中的修改
从Odoo 10开始,记录集操作保留了排序。此前的 Odoo 版本中,记录集操作不一定会保留排序,虽然加运算和切片已知是保留排序的。
我们可以用如下运算通过删除或添加元素来修改记录集:
- self.author_ids |= author1:如果不存在author1,它会将author1加入记录集
- self.author_ids -= author1:如果author1存在于记录集中,会进行删除
- self.author_ids = self.author_ids[:-1]删除最后一条记录
关联字段包含记录集值。many-to-one 可包含单例记录集,to-many字段包含任意数量记录的记录集。
使用底层 SQL 和数据库事务
数据库引入运算在一个数据库事务上下文中执行。通常我们无需担心这点,因为服务器在运行模型方法时会进行处理。但有些情况下,可能需要对事务进行更精细控制。这可通过数据库游标self.env.cr来实现,如下所示:
- self.env.cr.commit()执行事务缓冲的写运算
- self.env.cr.rollback()取消上次 commit之后的写运算,如果尚未 commit,则回滚所有操作
小贴士: 在shell会话中,直到执行self.env.cr.commit()时数据操作才会在数据库中生效
通过游标execute() 方法,我们可以直接在数据库中运行 SQL 语句。它接收一个要运行的SQL 语句,以及第二个可选参数:一个用作 SQL 参数值的元组或列表。这些值会用在%s占位符之处。
- ℹ️注意:
在cr.execute() 中我们不应直接编写拼接参数的SQL查询。众所周知这样做会带来SQL注入攻击的安全风险。保持使用%s占位符并通过第二个参数来传值。
如果使用SELECT查询,会获取到记录。fetchall() 函数以元组列表的形式获取所有行,dictfetchall()则以字典列表的形式获取,示例如下:
1 | >>> self.env.cr.execute("SELECT id, login FROM res_users WHERE login=%s OR id=%s", ('demo',1)) |
还可以使用数据操纵语言(DML) 来运行指令,如UPDATE和INSERT。因为服务器保留数据缓存,这可能导致与数据库中实际数据的不一致。出于这个原因,在使用原生DML后,应使用self.env.cache.invalidate()清除缓存。
ℹ️注意:
直接在数据库中执行SQL语句可能会导致数据不一致,请仅在确定时进行该操作。
总结
在本文中,我们学习了如何操作模型数据以及执行 CRUD 运算:创建、读取、更新和删除数据。这是实现我们的业务逻辑和自动化的基石。
对于ORM API的测试,我们使用了Odoo交互式 shell 命令行。我们通过self.env环境运行了命令,该环境可访问模型注册表并提供命令运行相关信息的上下文,如当前语言 lang 和时区 tz。
记录集使用search(
除直接为单例分配值外,我们还可以使用write(
记录集可被检查和操作,检查运算符包含in和not in。重构运算符包含并集的|,交集的&以及切片:。可用的转换包含提取 ID 列表的.ids、.mapped(
最后,通过self.env.cr中暴露的游标对象可控制底层 SQL 运行和事务控制。
在下一篇文章中,我们将为模型添加业务逻辑层,实现通过ORM API来自动化操作的模型方法。
☞☞☞第八章 Odoo 12开发之业务逻辑 - 业务流程的支持
本文首发地址:Alan Hou 的个人博客