第一章 模型
我们在第二部分已经了解过模型的基础特性,这里我们将深入模型的底层架构,了解其更多的奥秘.
模型元类
Model的基类是BaseModel,而BaseModel的基类是MetaModel,MetaModel是一个元类,它的主要作用是注册每个模块中的模型。
class MetaModel(api.Meta):
""" The metaclass of all model classes.
Its main purpose is to register the models per module.
"""
module_to_models = defaultdict(list)
def __new__(meta,name,bases,attrs):
# this prevents assignment of non-fields on recordsets
attrs.setdefault('__slots__',())
# this collects the fields defined on the class (via Field.__set_name__())
attrs.setdefault('_field_definitions',[])
if attrs.get('_register',True):
# determine '_module'
if '_module' not in attrs:
module = attrs['__module__']
assert module.startswith('odoo.addons.'),\
f"Invalid import of {module}.{name},it should start with 'odoo.addons'."
attrs['_module'] = module.split('.')[2]
# determine model '_name' and normalize '_inherits'
inherit = attrs.get('_inherit',())
if isinstance(inherit,str):
inherit = attrs['_inherit'] = [inherit]
if '_name' not in attrs:
attrs['_name'] = inherit[0] if len(inherit) == 1 else name
return super().__new__(meta,name,bases,attrs)
模型元类在创建对象的时候,会初始化_field_definitions变量用来记录该模型定义的字段列表. 然后检查模型是否已经注册,对于已经注册的模型,将会检查它是否包含模块信息 ,没有的话将会自动解析模块名称到_module变量中.
从代码中我们也可以知道,模型元类在获取模块信息时要求模块的注册路径中必须包含odoo.addons前缀,这个特性在13.0版本开始引入,在13.0之前模块的路径其实可以定义在其他地方,13.0以后这块就已经变得严格起来了.
另外,从这里我们也可以看到_inherit属性的本质上就是一个列表,只不过odoo做了兼容处理,虽然我们可以直接输入一个对象模型的名称,但实际上odoo还是将其转换为了列表进行处理.
接下来我们再看一下模型元类的初始化方法:
def __init__(self,name,bases,attrs):
super().__init__(name,bases,attrs)
if not attrs.get('_register',True):
return
# Remember which models to instantiate for this module.
if self._module:
self.module_to_models[self._module].append(self)
if not self._abstract and self._name not in self._inherit:
# this class defines a model: add magic fields
def add(name,field):
setattr(self,name,field)
field.__set_name__(self,name)
def add_default(name,field):
if name not in attrs:
setattr(self,name,field)
field.__set_name__(self,name)
add('id',fields.Id(automatic=True))
add(self.CONCURRENCY_CHECK_FIELD,fields.Datetime(
string='Last Modified on',automatic=True,
compute='_compute_concurrency_field',compute_sudo=False))
add_default('display_name',fields.Char(
string='Display Name',automatic=True,compute='_compute_display_name'))
if attrs.get('_log_access',self._auto):
add_default('create_uid',fields.Many2one(
'res.users',string='Created by',automatic=True,readonly=True))
add_default('create_date',fields.Datetime(
string='Created on',automatic=True,readonly=True))
add_default('write_uid',fields.Many2one(
'res.users',string='Last Updated by',automatic=True,readonly=True))
add_default('write_date',fields.Datetime(
string='Last Updated on',automatic=True,readonly=True))
与14.0不同,15.0中的魔法字段的定义放到了初始化方法_init_中,而14.0中则是放在BaseModel的_add_magic_fields方法中,15.0已经取消了这个方法. _add_magic_fields方法其实很有用处,我们可以利用它来实现一些灵活字段的添加,例如附录中关于产品信息同时展示多公司的模块中就利用了该方法实现多公司同时显示在同一界面中。
NewId
在odoo中,如果你创建了一个记录但尚未保存,那么系统会自动分配给当前新记录一个临时的ID,这个ID的类型即NewId。我们先来看一下 NewId的定义:
class NewId(object):
""" Pseudo-ids for new records,encapsulating an optional origin id (actual
record id) and an optional reference (any value).
"""
__slots__ = ['origin','ref']
def __init__(self,origin=None,ref=None):
self.origin = origin
self.ref = ref
def __bool__(self):
return False
def __eq__(self,other):
return isinstance(other,NewId) and (
(self.origin and other.origin and self.origin == other.origin)
or (self.ref and other.ref and self.ref == other.ref)
)
def __hash__(self):
return hash(self.origin or self.ref or id(self))
def __repr__(self):
return (
"<NewId origin=%r>" % self.origin if self.origin else
"<NewId ref=%r>" % self.ref if self.ref else
"<NewId 0x%x>" % id(self)
)
NewId的代码很容易解读,从源代码中我们可以看到NewId对象只有两个属性:origin和ref。这两个属性记录了NewId的来源和关联内容。
如果对NewId进行布尔取值,那么它将永远返回Flase。
NewId对象的相等比较,实际上是比的它的源记录和参考是否相等,只要其中一个相等,就判定为两个NewId是相等的.
NewId常见的使用场景是X2many的Onchange事件中,x2many的字段在更新时总是将原有的记录删除再创建一个新的,再尚未保存到数据库时,新的记录的只即NewId。
魔法字段
在模型中有一类字段会由系统自动生成,这就是魔法字段,当前版本的魔法字段主要由下面两种组成:
- 日志访问字段: 即 create_uid,write_uid,create_date,write_date
- 记录ID: id
正如前面代码所示,只有模型的_log_access显示为True或者_auto属性为真时,这些日志访问字段才会自动添加到模型中.
另外,我们在开发中也经常用到一个属性ids,它虽然不是由字段定义的,但它跟id字段的关系却很密切。
@property
def ids(self):
""" Return the list of actual record ids corresponding to ``self``. """
return list(origin_ids(self._ids))
还有一个被称为CONCURRENCY_CHECK_FIELD的计算字段,不存储在数据库中,CONCURRENCY_CHECK_FIELD字段的作用是根据自定义的计算方法来计算并发性字段的的值。
根据源码分析可以得出结论:
- 如果模型启用了日志记录功能,那么最后的更新时间的值根据优先级取自 write_date> create_date > fields.Datetime.now()
- 如果模型没有启用日志记录功能,那么最后的更新时间值就是fields.Datetime.now()
inherit与inherits的区别
我们在第一部分第八章时介绍过inherit与inherits的使用方法,我们这里看一下它们的定义:
_inherit = ()
_inherits = frozendict()
inherit是一个元组,inherits 是一个不可变字典. 如果多个模型中定义了同名的字段, 那么关联的字段将会与inherits列表中的最后一个字段保持一致.
记录的有效或无效
我们知道,系统中有些字段可以被设置为归档(Archived)状态,处在归档状态的记录默认将不会被搜索到.
model中的_active_name属性就是来指定这个标识字段,默认情况下为None
如果某个模型种没有定义active字段同时也没有使用_active_name属性指定相应的字段,那么我们可以通过添加该字段从而是该模型拥有设置归档的功能.
active = fields.Boolean("Active",default=True)
CUID方法详解
create方法
关于create方法,我们其实已经很熟悉了, 但是真正了解create方法底层应用的同学可能并不多.今天我们就来详细地解读一下这些我们"最熟悉的陌生人".
首先,我们来温习一下, create方法的作用:
create方法用接收一个包含若干字段的参数来创建一条新记录.
下面是我们通常定义create方法的样子:
@api.model
def create(self, vals):
...
return super().create(vals)
没什么特别的, 但是很多人不知道的是, vals实际上可以是一个数组, 也就是说create可以一次创建多条记录.
这在create方法的定义中显而易见:
@api.model_create_multi
@api.returns('self', lambda value: value.id)
def create(self, vals_list):
""" create(vals_list) -> records
Creates new records for the model.
The new records are initialized using the values from the list of dicts
``vals_list``, and if necessary those from :meth:`~.default_get`.
:param Union[list[dict], dict] vals_list:
values for the model's fields, as a list of dictionaries::
[{'field_name': field_value, ...}, ...]
For backward compatibility, ``vals_list`` may be a dictionary.
It is treated as a singleton list ``[vals]``, and a single record
is returned.
see :meth:`~.write` for details
:return: the created records
:raise AccessError: * if user has no create rights on the requested object
* if user tries to bypass access rules for create on the requested object
:raise ValidationError: if user tries to enter invalid value for a field that is not in selection
:raise UserError: if a loop would be created in a hierarchy of objects a result of the operation (such as setting an object as its own parent)
"""
那么api.model是如何处理多个记录的呢?
实际上, 这就要从我们的请求发起的情况讲起了:
Request从浏览器端发起以后, 由controller进行处理, controller首先根据按钮点击事件call_kw方法传递过来的模型和方法名称进行解析, 找到指定的模型M的指定方法F.
- 如果F的装饰器是model: 那么则处理后返回记录集
- 如果F的装饰器是model_create: 那么则处理后返回记录集的ids
- 如果F的装饰器既不是model也不是model_create: 那么返回multi结果.
这里可能有同学要问了, 既然model和multi都是返回记录集, 那么他们的区别是什么?
答案就在两个装饰器的底层方法中:
def _call_kw_model(method, self, args, kwargs):
context, args, kwargs = split_context(method, args, kwargs)
recs = self.with_context(context or {})
_logger.debug("call %s.%s(%s)", recs, method.__name__, Params(args, kwargs))
result = method(recs, *args, **kwargs)
return downgrade(method, result, recs, args, kwargs)
...
def _call_kw_multi(method, self, args, kwargs):
ids, args = args[0], args[1:]
context, args, kwargs = split_context(method, args, kwargs)
recs = self.with_context(context or {}).browse(ids)
_logger.debug("call %s.%s(%s)", recs, method.__name__, Params(args, kwargs))
result = method(recs, *args, **kwargs)
return downgrade(method, result, recs, args, kwargs)
看出来了吗, 他们的区别就是model方法装饰的record只是一个空的记录, 它的ids被刻意丢弃了.
瞬态模型的处理逻辑
我们知道瞬态模型(TransientModel)是会被系统定期进行清理,那么它的具体逻辑是如何的呢?
我们找到了基于时间和数量限制的逻辑.
@api.autovacuum
def _transient_vacuum(self):
if self._transient_max_hours:
# Age-based expiration
self._transient_clean_rows_older_than(self._transient_max_hours * 60 * 60)
if self._transient_max_count:
# Count-based expiration
self._transient_clean_old_rows(self._transient_max_count)
def _transient_clean_old_rows(self,max_count):
# Check how many rows we have in the table
query = 'SELECT count(*) FROM "{}"'.format(self._table)
self._cr.execute(query)
[count] = self._cr.fetchone()
if count > max_count:
self._transient_clean_rows_older_than(300)
def _transient_clean_rows_older_than(self,seconds):
# Never delete rows used in last 5 minutes
seconds = max(seconds,300)
query = """
SELECT id FROM "{}"
WHERE COALESCE(write_date,create_date,(now() AT TIME ZONE 'UTC'))::timestamp
< (now() AT TIME ZONE 'UTC') - interval %s
""".format(self._table)
self._cr.execute(query,["%s seconds" % seconds])
ids = [x[0] for x in self._cr.fetchall()]
self.sudo().browse(ids).unlink()
总结下来就是:
- 如果临时表的记录存在时间长于配置中的限制时间,那么,这些记录将被直接删除. 但是系统会保留最后5分钟内创建/修改的记录,哪怕你配置中设置为1分钟.
- 如果临时表的数据量大于配置中的数量限制,那么除5分钟以内被创建/修改的记录都将被删除,而不是只删除多余的记录.
也就是说5分钟的内的记录是不会被清理的,这是写死在代码里的逻辑.
获取字段
BaseModel内部封装了从数据库中获取字段的方法,该方法是_fetch_field.
def _fetch_field(self,field):
""" Read from the database in order to fetch ``field`` (:class:`Field`
instance) for ``self`` in cache.
"""
self.check_field_access_rights('read',[field.name])
# determine which fields can be prefetched
if self._context.get('prefetch_fields',True) and field.prefetch:
fnames = [
name
for name,f in self._fields.items()
# select fields that can be prefetched
if f.prefetch
# discard fields with groups that the user may not access
if not (f.groups and not self.user_has_groups(f.groups))
# discard fields that must be recomputed
if not (f.compute and self.env.records_to_compute(f))
]
if field.name not in fnames:
fnames.append(field.name)
self = self - self.env.records_to_compute(field)
else:
fnames = [field.name]
self._read(fnames)
_fetch_field方法从数据库中获取字段数据时,首先会调用check_field_access_right方法用来检查当前用户是否有权读取该字段. 然后_fetch_field会结合当前上下文中的prefetch_fields字段来判断该字段是否需要预读,然后调用_read方法读取字段内容.
_read方法
我们之前在第二部分提到过,read方法的底层实现是_read方法, 现在我们就来详细分析_read方法内部的详细逻辑。
_read方法的作用主要有以下两种:
- 用来从数据库中读取参数中传入的记录集的指定的字段,并将字段的值存到缓存中。
- 同时,会将访问错误(AccessError)存在缓存中,非存储类型的字段将被忽略。
if not self:
return
self.check_access_rights('read')
# if a read() follows a write(),we must flush updates,as read() will
# fetch from database and overwrites the cache (`test_update_with_id`)
self.flush(fields,self)
_read方法的执行逻辑:
- _read方法内部会首先查看记录集是否为空,如果是空,那么将直接返回。
- 然后会检查对当前用户进行鉴权。
- 然后调用了flush方法将字段的更改更新到缓存中。这里是为了read方法跟在write方法之后覆盖掉write方法更新的值,因为read方法会从数据库获取值并刷新缓存。
接下来开始真正的读取操作,odoo会将当前存储类型的字段和继承的存储类型字段作为表的列名,组建sql查询语句,并执行查询操作。如果查询结果不为空,那么执行翻译操作,将查询结果根据当前上下文中的语言设置翻译成相应的值,并更新缓存中的值。
missing = self - fetched
if missing:
extras = fetched - self
if extras:
raise AccessError(
_("Database fetch misses ids ({}) and has extra ids ({}),may be caused by a type incoherence in a previous request").format(
missing._ids,extras._ids,
))
# mark non-existing records in missing
forbidden = missing.exists()
if forbidden:
raise self.env['ir.rule']._make_access_error('read',forbidden)
最后,_read方法会校验sql语句查询出来的结果跟参数中的记录是否一致,如果不一致,那么将抛出异常,具体地将就是:
- 如果记录集与查询结果集相交,那么说明查询出了不应该查询出来的结果,可能是并行计算出了问题,因此会抛出一个Access异常的错误。
- 如果记录集包含查询结果集,那么说明其中有某些记录因为记录规则的问题被过滤掉了,因此会抛出规则读取异常。
现在,我们回过头来看一下ORM一章中提到的read方法的具体逻辑:
fields = self.check_field_access_rights('read',fields)
# fetch stored fields from the database to the cache
stored_fields = set()
for name in fields:
field = self._fields.get(name)
if not field:
raise ValueError("Invalid field %r on model %r" % (name,self._name))
if field.store:
stored_fields.add(name)
elif field.compute:
# optimization: prefetch direct field dependencies
for dotname in field.depends:
f = self._fields[dotname.split('.')[0]]
if f.prefetch and (not f.groups or self.user_has_groups(f.groups)):
stored_fields.add(f.name)
self._read(stored_fields)
# retrieve results from records; this takes values from the cache and
# computes remaining fields
data = [(record,{'id': record._ids[0]}) for record in self]
use_name_get = (load == '_classic_read')
for name in fields:
convert = self._fields[name].convert_to_read
for record,vals in data:
# missing records have their vals empty
if not vals:
continue
try:
vals[name] = convert(record[name],record,use_name_get)
except MissingError:
vals.clear()
result = [vals for record,vals in data if vals]
return result
- 首先,read方法在内部对要读取的字段进行了分类。
- 对于存储类型字段和具有依赖字段的计算字段(预读且拥有访问权限), 调用_read方法进行读取,并将值更新到缓存中。
- 然后,read方法根据参数是否指定了经典读取模式决定是否调用name_get方法组织结果,最后将结果返回.
总结下来就是,read方法只对存储类型和具有依赖字段的字段生效,read方法会忽略掉非存储类型的字段. 对于非存储类型字段的读取方法,odoo采用了描述符协议的方式进行处理,具体请参考字段一章.
另外,我们可以看到对于存储类型和依赖的计算字段,都存储在了缓存中,那么odoo中的缓存是如何设计以及实现的呢?这里先挖一个坑,等我们继续旅行到缓存一节时就会揭开它神秘的面纱了。
方法的重载
最常见的重载基类的方法莫过于三大方法(create,write,unlink)了。
常规的重载方法
例如,我们重载create和write、unlink方法时,通常会像下面的这种方式写:
class Demo(models.Model):
@api.model
def create(self,vals):
......
return super(Demo,self).create(vals)
我们可以使用super的这种方式来调用父类方法,也可以不调用,直接返回结果,也就是覆盖掉了父类的同名方法。
非常规的使用方法
考虑如下的场景:
我有一个类继承自产品模板类(product.template),有一个用来同步数据的方法A,在方法A的内部我使用了Write方法来更新产品数据,同时我还在类的内部重载了Write方法,但是我又希望调用A方法的时候不触发我重载的Write方法,怎么实现呢?
既然不希望调用本类下面的Write方法,那么就需要想办法绕过这个方法。同样是使用super方法,但是参数变了,变成父类即可。
文字可能说不清楚,下面来看代码:
from odoo.addons.orssica_base.models.models import ProductTemplate
class product_template(models.Model):
_inherit = "product.template"
def get_products_from_erp(self,from_date=None):
...
super(ProductTemplate,product).write(prd_data)
@api.multi
def write(self,vals):
"""更新产品"""
...
如上所示,我们的get_products_from_erp方法中虽然调用了write方法,但是由于super的第一个参数是ProductTemplate而不是product_template,因此不会调用product_template的write方法,而是直接调用了ProductTemplate的write方法,因而实现了我们的需求。
基础模型的重载
在第一部分,我们知道了模型可以被继承,方法可以被重载,这都是对常规的类来说的,如果我们有种需求要对基类的某些方法进行重载,该怎么做呢?可能有同学想到了,直接在源码的修改,但这是我们极为不推荐的一种方式。在比较早的版本,实现这个需求比较绕,要用到_register_hook方法,所幸的是从10.0开始,官方给我们提供了一种修改基础模型的途径。
from odoo import api, fields, models, _
class BaseModel(models.AbstractModel):
_inherit = "base"
...
我们只需要新建一个抽象类,然后继承自"base"模块,就可以对BaseModel中的方法进行重载了。
合理利用基础模型的重载将有利于我们减少代码的重复率,提高编程效率。举例来说,笔者在某个项目中经常需要用到使用向导来引导用户操作的方式,而使用向导的一个特性就是需要将当前的模型的记录传递到向导中,以方便向导初始化数据。
因为这个特性是非常常见的,因此我们可以考虑将这段代码做成通用的,放到BaseModel中,这样,其他模型便可以直接使用而不用在手动实现。
def _get_active_ids(self):
active_model, active_ids = self._context.get("active_model"), self._context.get("active_ids")
return self.env[active_model].browse(active_ids)
类似这种功能特性的代码,我们可以集中放到一个工具模块中以方便我们日后使用。
笔者这里提供了一个公开开源的基础模块,有兴趣的同学可以到我的github账号获取。
视图化模型
我们在第二部分的时候提到过, 通常持久化的方案是是使用数据表, 但在实际的应用中, 我们也会用到使用视图作为数据来源的情况.
下面的例子来源于客户的实际使用例子, 客户想要一个发货报表, 其中涉及到销售订单, 发货单等多个表, 如果按照常规的思路进行查询, 在将结果组织成报表, 代码复杂度将明显提高.为了提高效率, 我们使用视图作为结果模型的数据源部分.
class juhui_partner_report(models.Model):
_auto = False
_name = "juhui.partner.report"
_table = "view_juhui_parter_report"
_description = "客户发货报表"
_order = 'date_done desc'
partner_id = fields.Many2one("res.partner",string="客户")
order_id = fields.Many2one("sale.order",string="订单号")
product_id = fields.Many2one("product.product",string="产品")
lot_id = fields.Many2one("stock.production.lot",string="序列号")
customer_lot_no = fields.Char("客户序列号")
picking_id = fields.Many2one("stock.picking",string="调拨单号")
carrier_id = fields.Many2one("delivery.carrier",string="承运方")
carrier_tracking_ref = fields.Char("跟踪参考")
date = fields.Datetime("日期")
date_done = fields.Datetime("完成日期")
mgr_user_id = fields.Many2one("res.users",string="库管员")
company_id = fields.Many2one('res.company',string="公司")
user_id = fields.Many2one("res.users",string="销售员")
def init(self):
"""初始化视图"""
tools.drop_view_if_exists(self.env.cr, "view_juhui_parter_report")
sql = """
...
"""
self.env.cr.execute(sql)
与常规模型相比,只是将_auto属性设置为了False,这样odoo在安装或升级时就不会再创建数据表。然后在模型的初始化方法中,对数据源的视图做了定义或刷新。
然后我们跟正常模型一样去创建视图布局文件就可以了,当我们每次进行查询的时候,视图里的数据都是从数据源视图中获取到的。 然后我们再跟正常模型一样写视图布局就可以了, 当我们安装/升级模块的时候视图的定义就会被重新刷新.
这里有个需要注意的问题, 就是我们再定义视图的时候需要有一个ID字段, 这个ID不能使用rowno作为id, 否则将导致视图查询结果混乱.