第六章 基础模型

基础模型(Basic Model)包含了Python模型数据与web client通信所必须的全部业务逻辑,其作用是给出了一个对其余的web client对象来说足够简单和统一的API,以帮助它们(视图、字段Widget)来实现查询和更新数据库中的数据 的需求。

基础模型本质上是一个哈希字典,其值可能是整数、对象或者元对象。每个哈希字典的对象都代表了一小块数据,并且可以通过id作为key进行重载或者更新。

数据锚点

下面是一个数据锚点(data point)的样例:

var dataPoint = {
    _cache: {Object|undefined}
    _changes: {Object|null},
    aggregateValues: {Object},
    context: {Object},
    count: {integer},
    data: {Object|Object[]},
    domain: {*[]},
    fields: {Object},
    fieldsInfo: {Object},
    getContext: {function},
    getDomain: {function},
    getFieldNames: {function},
    groupedBy: {string[]},
    id: {integer},
    isOpen: {boolean},
    loadMoreOffset: {integer},
    limit: {integer},
    model: {string},
    offset: {integer},
    openGroupByDefault: {boolean},
    orderedBy: {Object[]},
    orderedResIDs: {integer[]},
    parentID: {string},
    rawContext: {Object},
    relationField: {string},
    res_id: {integer|null},
    res_ids: {integer[]},
    specialData: {Object},
    _specialDataCache: {Object},
    static: {boolean},
    type: {string} 'record' | 'list'
    value: ?,
};

数据锚点是WC中与数据操作相关的对象, 要想理解WC的运作方式, 就必须要理解数据锚点的数据结构

数据类型

数据锚点有两种类型: record和list. 数据锚点的类型决定了页面在加载过程中获取数据的方式. 具体参见数据加载过程一节.

数据

数据锚点的data属性包含了该数据锚点的数据

字段

fields属性包含了字段的数据结构

字段信息

fieldsInfo包含了字段相关的描述信息

需要注意的几点:

  • id: 与res_id完全没有关系的id,是web client端的概念,与数据库中的记录没有任何关系。
  • res_id: 数据库中的记录id,通常情况下,它是由模型名称+下划线+数据库记录ID组成(res.partner_1)。如果你看到的是由virtual+下划线+ID组成的数据(virtual_1176),那么说明改数据尚未保存到数据库中(通常在数据的CREATE模式下)。
  • res_ids: 代表了数据点被使用的上下文。举例来说,在list视图中打开了某一个form记录,假设被打开的记录是1,那么res_id=1,而res_ids可能的值是[1,2,3]。
  • offset: 分页中使用,如果需要加载另外一页时使用。
  • count: 被操作的记录数,不能使用res_id的原因是,res_ids的数据可能很大,或者由于domain或分页的原因,res_ids的值并非全部的数据。
  • model: 模型的名称,例如'res.partner'
  • fields: 从模型中获取的所有的字段的描述信息,它的属性可能被视图更新,所以字段类型依赖于数据点的上下文。
  • fields_names: 相关字段名称组成的列表,通常,它暗示了视图中出现的字段列表,只有出现在视图中的字段才应该被加载到这里。
  • _cache和_changes都是私有属性,不应该在Model外被使用。

BasicModel的特性

可分组的字段

我们知道分组视图可以显示某些列的汇总信息,那么什么类型的字段可以被分组,是由BasicModel的AGGREGATABLE_TYPES属性指定的,只有三种字段可以被分组: float、integer和monetary。

const AGGREGATABLE_TYPES = ['float', 'integer', 'monetary'];

X2Many字段的命令集

我们要在Model中操作各种字段的读写, 其中较为复杂的应属X2Many类型的字段。我们在Python中知道由6种原生命令的支持, 在WC的模型中同样设置的类似的几种命令。

var x2ManyCommands = {
    // (0, virtualID, {values})
    CREATE: 0,
    create: function (virtualID, values) {
        delete values.id;
        return [x2ManyCommands.CREATE, virtualID || false, values];
    },
    // (1, id, {values})
    UPDATE: 1,
    update: function (id, values) {
        delete values.id;
        return [x2ManyCommands.UPDATE, id, values];
    },
    // (2, id[, _])
    DELETE: 2,
    delete: function (id) {
        return [x2ManyCommands.DELETE, id, false];
    },
    // (3, id[, _]) removes relation, but not linked record itself
    FORGET: 3,
    forget: function (id) {
        return [x2ManyCommands.FORGET, id, false];
    },
    // (4, id[, _])
    LINK_TO: 4,
    link_to: function (id) {
        return [x2ManyCommands.LINK_TO, id, false];
    },
    // (5[, _[, _]])
    DELETE_ALL: 5,
    delete_all: function () {
        return [5, false, false];
    },
    // (6, _, ids) replaces all linked records with provided ids
    REPLACE_WITH: 6,
    replace_with: function (ids) {
        return [6, false, ids];
    }
};

x2ManyCommands定义了6种命令:

命令字 数据格式
CREATE (0, virtualID, {values})
UPDATE (1,id,{values})
DELETE (2, id[, _])
FORGET (3, id[, _])
LINK_TO (4, id[, _])
DELETE_ALL (5[, [, ]])
REPLACE_WITH (6, _, ids)

从代码中,我们可以总结出来一点,就是对web client的x2many Widget而言其实不存在什么更新编辑之说,其编辑的本质是先进行删除又重新创建,这个特性造成的结果就是(我们在onchange一章中提到过),onchange事件返回的永远是一个新创建的NewID,而不是原先的记录ID

基础模型

下面我们开始正式介绍基础模型的特性。

分组自动折叠

基础模型中有一个属性OPEN_GROUP_LIMIT, 意思是当数据量超过这个数据,分组将进行折叠操作, 其默认值是10。

OPEN_GROUP_LIMIT: 10

非缓存模型

非缓存模型指当创建 编辑和删除操作进行时, 此模型列表种的模型将不进行缓存

noCacheModels: [
    'ir.actions.act_window',
    'ir.filters',
    'ir.ui.view',
]

初始化

基础模型的初始化过程,先实例化了一个互斥锁,用来确保操作能够按序进行。然后初始化了一个批量RPC请求属性batchedRPCsRequests,然后实例化了一个空对象localData用来存储后续的数据。

init: function () {
    // this mutex is necessary to make sure some operations are done
    // sequentially, for example, an onchange needs to be completed before a
    // save is performed.
    this.mutex = new concurrency.Mutex();

    // this array is used to accumulate RPC requests done in the same call
    // stack, so that they can be batched in the minimum number of RPCs
    this.batchedRPCsRequests = [];

    this.localData = Object.create(null);
    this._super.apply(this, arguments);
},

这里的localData即我们前面说过的数据锚点

添加默认记录

我们可以使用addDefaultRecord方法来给list模型添加默认记录。

addDefaultRecord: function (listID, options) {
    ...
    return this._makeDefaultRecord(list.model, params).then(function (id) {
        list.count++;
        if (position === 'top') {
            list.data.unshift(id);
        } else {
            list.data.push(id);
        }
        var record = self.localData[id];
        list._cache[record.res_id] = id;
        return id;
    });
}

addDefaultRecord方法内部通过调用_makeDefaultRecord方法创建了一条新数据,并将该数据添加到了列表中。

方法接受两个参数, listID代表list部件, options中可以接收一个position参数, 指明添加数据时是弹窗还是在属性列表中直接修改, 其对应的值为top和bottom。

addDefaultRecord的适用场景是list或者kanban的controller, 并不适用于form视图中的x2many字段, 因为form视图中的x2many是通过命令字的方式进行保存的。

添加字段信息

对于给定的数据锚点datapointID, 我们可以使用addFieldsInfo来补齐它的字段和字段信息, 该方法对于多视图中来回切换的记录有效, 例如form视图中的one2many字段

addFieldsInfo: async function (dataPointID, viewInfo) {
    ...
}

addFieldsInfo方法接受两个参数, 第一个参数为dataPointID, 第二个参数为视图. 方法内部会根据数据锚点的类型进行处理, 如果数据类型是list, 那么将遍历数据锚点缓存中的子数据锚点, 如果数据是record, 那么将针对这些字段中的one2many和many2many字段进行循环遍历读取

应用RawChanges

在onchange方法的返回值中, 有可能会包含我们当前页面没有的字段值, 像one2many字段, 我们只知道在当前页面中的字段, 但是不知道如果用户单击某条记录而弹出的对话框中的字段. 这样我们就无法处理, 于是系统就将这些changes保存到了_rawChanges变量中, 等当前记录切换视图时, 这些记录必须要被处理了, 再调用applyRawChanges方法来应用这些改变

applyRawChanges: function (recordID, viewType) {
    var record = this.localData[recordID];
    return this._applyOnChange(record._rawChanges, record, { viewType }).then(function () {
        return record.id;
    });
},

方法内部调用了_applyOnChange方法, 并返回该记录的ID

是否可以被丢弃

如果新建的记录还未保存时不需要了,那么应该被丢弃,canBeAbandoned方法即用来判断一条记录是否应该被丢弃.

canBeAbandoned: function (id) {
    ...
}

删除记录

我们可以使用deleteRecords来删除记录集, 如果记录集有父项, 那么重新载入

deleteRecords: function (recordIds, modelName) {
    ...
}

丢弃所有更改

如果要丢弃当前模型中的所有更改, 那么应该使用discardChanges方法

discardChanges: function (id, options) {
    ...
}

discardChanges方法将所有储存在_changes变量中的更改都丢弃了.

复制记录

复制记录使用duplicateRecord方法:

duplicateRecord: function (recordID) {
    ...
}

冻结序列头

对于list资源, 可以使用freezeOrder方法固定它的记录顺序

freezeOrder: function (listID) {
    ...
}

生成默认值

当一条新记录被创建时, generateDefaultValues方法将被调用用来给当前记录生成默认值. 这些被生成的默认值将存储在记录的_changes属性中, 对于关系字段, 将创建子数据锚点,并获取缺失的关系字段数据

generateDefaultValues(recordID, options = {}) {
    ...
}

generateDefaultValues也可以在form视图中的one2many子记录被打开时调用, 但是不对list和kanban视图有效(仅关系字段)

获取当前记录的显示名称

获取当前记录的显示名称, 使用getName方法

getName: function (id) {
    var record = this.localData[id];
    var returnValue = '';
    if (record._changes && 'display_name' in record._changes) {
        returnValue = record._changes.display_name;
    }
    else if ('display_name' in record.data) {
        returnValue = record.data.display_name;
    }
    return returnValue || _t("New");
}

这与我们之前的经验相吻合, odoo会自动给每个记录生成一个display_name用来展示它的名称. 如果当前记录的变更数据中包含display_name, 那么当前记录将使用变更数据中的display_name当作显示名称, 否则使用记录原本的显示名称. 如果记录是新建的那么将显示New.

脏数据判断

首先我们先来看一下什么是脏数据, 脏数据的定义是记录中有发生变更但没有保存到数据库中的数据

记录中有个标志位_isDirty用来标识该记录是否被污染

 isDirty: function (id) {
    var isDirty = false;
    this._visitChildren(this.localData[id], function (r) {
        if (r._isDirty) {
            isDirty = true;
        }
    });
    return isDirty;
}

当前记录被污染或者子记录被污染, 该记录就被判断为脏数据.

新数据判断

如果记录处在创建过程中, 数据库中并未存在相应的记录ID, 那么该条记录就是新数据.

如果数据锚点的类型不是record, 那么将始终被认为是旧数据.

isNew: function (id) {
    var data = this.localData[id];
    if (data.type !== "record") {
        return false;
    }
    var res_id = data.res_id;
    if (typeof res_id === 'number') {
        return false;
    } else if (typeof res_id === 'string' && /^[0-9]+-/.test(res_id)) {
        return false;
    }
    return true;
}

从代码可知, 如果数据中的res_id是数字类型或者虽然是字符但却以数字开头, 那么也不会被当作新数据.

这种有数字和-开头的字符ID, 被称为虚拟ID

制造数据

开发过程中, 我们有可能希望widget独立于视图(view), 因此我们需要一种能够制造假数据的方法, 而不是真的去数据库中获取.

makeRecord: function (model, fields, fieldInfo) {
    ...
}

makeRecord方法就是为此而生的.

通知变更

notifyChanges: function (record_id, changes, options) {
    return this.mutex.exec(this._applyChange.bind(this, record_id, changes, options));
}

这是一个很重要的方法, 所有字段的变化都将通过该方法的处理. 如果需要, 此方法会触发onchange事件, 并做出与之相应的变化

移除行

有这么一种场景, 对于one2many字段, 当用户点击了添加一行, 且该行内有必填字段, 下一步用户点了其他地方, 此时新增的这行应该被移除, 但是又没有必要去触发notifyChanges事件, 这时候就可以使用removeLine方法将该行删除同时不用触发notifyChanges

removeLine: function (elementID) {
    ...
}

获取字段列表

基础模型中有一个方法[_getFieldNames](https://github.com/jellyfrank/odoo/blob/14.0/addons/web/static/src/js/views/basic/basic_model.js#L3697)用来获取给定元素的所在的视图列表.

_getFieldNames: function (element, options) {
    var fieldsInfo = element.fieldsInfo;
    var viewType = options && options.viewType || element.viewType;
    return Object.keys(fieldsInfo && fieldsInfo[viewType] || {});
},

需要说明的是, 正常情况下使用此方法可以获取页面中出现的字段列表(含不可见字段), 但是对于X2Many的字段来的open动作来说, 只能获取到列表视图中的字段. 这可能会导致隐藏的问题.

数据加载过程

我们知道页面在打开的时候会渲染页面,然后加载相应的模型数据, 那么整个过程是什么样子的呢? 现在,我们就来一窥究竟.

用户在页面进行操作之后, 通常会进行加载数据, 整个数据加载的入口是load方法.

load方法会调用基础模型中的私有方法_reload

_load: function (dataPoint, options) {
    if (options && options.onlyGroups &&
        !(dataPoint.type === 'list' && dataPoint.groupedBy.length)) {
        return Promise.resolve(dataPoint);
    }

    if (dataPoint.type === 'record') {
        return this._fetchRecord(dataPoint, options);
    }
    if (dataPoint.type === 'list' && dataPoint.groupedBy.length) {
        return this._readGroup(dataPoint, options);
    }
    if (dataPoint.type === 'list' && !dataPoint.groupedBy.length) {
        return this._fetchUngroupedList(dataPoint, options);
    }
},

我们从中可以看出, 数据加载过程会根据数据锚点的类型执行相应的数据获取动作. 可以简单地归纳为以下几种:

  • 如果数据锚点类型为record, 那么执行_fetchRecord动作获取页面字段的数据(通常对应Form页面)
  • 如果数据锚点类型为list且含有分组标志, 那么执行_readGroup方法获取分组数据(对应数据列表的分组视图)
  • 如果数据锚点类型为list且不含有分组标志, 那么执行_fetchUngroupedList方法获取未分组的数据(对应数据列表视图)

获取页面数据

对于FORM视图, 我们来看一下是如何获取数据的. 从前面的学习中我们知道, Form视图获取数据的方法是_fetchRecord, 那么_fetchRecord内部是什么样子的呢?

 _fetchRecord: function (record, options) {
    var self = this;
    options = options || {};
    var fieldNames = options.fieldNames || record.getFieldNames(options);
    fieldNames = _.uniq(fieldNames.concat(['display_name']));
    return this._rpc({
            model: record.model,
            method: 'read',
            args: [[record.res_id], fieldNames],
            context: _.extend({bin_size: true}, record.getContext()),
        })
        .then(function (result) {
            if (result.length === 0) {
                return Promise.reject();
            }
            result = result[0];
            record.data = _.extend({}, record.data, result);
        })
        .then(function () {
            self._parseServerData(fieldNames, record, record.data);
        })
        .then(function () {
            return Promise.all([
                self._fetchX2Manys(record, options),
                self._fetchReferences(record, options)
            ]).then(function () {
                return self._postprocess(record, options);
            });
        });
},

我们从方法的定义中可以看出来, 数据加载的过程是:

  1. 首先根据当前页面类型,获取页面的字段数据列表,即前文所述的getFieldsNames方法的结果.
  2. 然后调用当前数据模型的rpc方法read,来从数据库(缓存)中获取这些字段的数据.
  3. 对获取的结果进行解析, 对于特殊的字段(X2Many和References)进行特殊化的处理.

results matching ""

    No results matching ""