Fields can now be marqued as read only.
```
stringifiedDomain: attr({
default: '[]',
readonly: true,
}),
```
closes odoo/odoo#60086
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
1123 lines
46 KiB
JavaScript
1123 lines
46 KiB
JavaScript
odoo.define('mail/static/src/model/model_manager.js', function (require) {
|
|
'use strict';
|
|
|
|
const { registry } = require('mail/static/src/model/model_core.js');
|
|
const ModelField = require('mail/static/src/model/model_field.js');
|
|
const { patchClassMethods, patchInstanceMethods } = require('mail/static/src/utils/utils.js');
|
|
|
|
/**
|
|
* Inner separator used between bits of information in string that is used to
|
|
* identify a dependent of a field. Useful to determine which record and field
|
|
* to register for compute during this update cycle.
|
|
*/
|
|
const DEPENDENT_INNER_SEPARATOR = "--//--//--";
|
|
|
|
/**
|
|
* Object that manage models and records, notably their update cycle: whenever
|
|
* some records are requested for update (either with model static method
|
|
* `create()` or record method `update()`), this object processes them with
|
|
* direct field & and computed field updates.
|
|
*/
|
|
class ModelManager {
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Public
|
|
//--------------------------------------------------------------------------
|
|
|
|
constructor(env) {
|
|
/**
|
|
* Inner separator used inside string to represent dependents.
|
|
* Set as public attribute so that it can be used by model field.
|
|
*/
|
|
this.DEPENDENT_INNER_SEPARATOR = DEPENDENT_INNER_SEPARATOR;
|
|
/**
|
|
* The messaging env.
|
|
*/
|
|
this.env = env;
|
|
|
|
//----------------------------------------------------------------------
|
|
// Various variables that are necessary to handle an update cycle. The
|
|
// goal of having an update cycle is to delay the execution of computes,
|
|
// life-cycle hooks and potential UI re-renders until the last possible
|
|
// moment, for performance reasons.
|
|
//----------------------------------------------------------------------
|
|
|
|
/**
|
|
* Set of records that have been created during the current update
|
|
* cycle. Useful to trigger `_created()` hook methods.
|
|
*/
|
|
this._createdRecords = new Set();
|
|
/**
|
|
* Tracks whether something has changed during the current update cycle.
|
|
* Useful to notify components (through the store) that some records
|
|
* have been changed.
|
|
*/
|
|
this._hasAnyChangeDuringCycle = false;
|
|
/**
|
|
* Set of records that have been updated during the current update
|
|
* cycle. Useful to allow observers (typically components) to detect
|
|
* whether specific records have been changed.
|
|
*/
|
|
this._updatedRecords = new Set();
|
|
/**
|
|
* Fields flagged to call compute during an update cycle.
|
|
* For instance, when a field with dependents got update, dependent
|
|
* fields should update themselves by invoking compute at end of
|
|
* update cycle. Key is of format
|
|
* <record-local-id><DEPENDENT_INNER_SEPARATOR><fieldName>, and
|
|
* determine record and field to be computed. Keys are strings because
|
|
* it must contain only one occurrence of pair record/field, and we want
|
|
* O(1) reads/writes.
|
|
*/
|
|
this._toComputeFields = new Map();
|
|
/**
|
|
* Map of "update after" on records that have been registered.
|
|
* These are processed after any explicit update and computed/related
|
|
* fields.
|
|
*/
|
|
this._toUpdateAfters = new Map();
|
|
}
|
|
|
|
/**
|
|
* Called when all JS modules that register or patch models have been
|
|
* done. This launches generation of models.
|
|
*/
|
|
start() {
|
|
/**
|
|
* Generate the models.
|
|
*/
|
|
Object.assign(this.env.models, this._generateModels());
|
|
}
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Public
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Returns all records of provided model that match provided criteria.
|
|
*
|
|
* @param {mail.model} Model class
|
|
* @param {function} [filterFunc]
|
|
* @returns {mail.model[]} records matching criteria.
|
|
*/
|
|
all(Model, filterFunc) {
|
|
const allRecords = Object.values(Model.__records);
|
|
if (filterFunc) {
|
|
return allRecords.filter(filterFunc);
|
|
}
|
|
return allRecords;
|
|
}
|
|
|
|
/**
|
|
* Register a record that has been created, and manage update of records
|
|
* from this record creation.
|
|
*
|
|
* @param {mail.model} Model class
|
|
* @param {Object|Object[]} [data={}]
|
|
* If data is an iterable, multiple records will be created.
|
|
* @returns {mail.model|mail.model[]} newly created record(s)
|
|
*/
|
|
create(Model, data = {}) {
|
|
const res = this._create(Model, data);
|
|
this._flushUpdateCycle();
|
|
return res;
|
|
}
|
|
|
|
/**
|
|
* Delete the record. After this operation, it's as if this record never
|
|
* existed. Note that relation are removed, which may delete more relations
|
|
* if some of them are causal.
|
|
*
|
|
* @param {mail.model} record
|
|
*/
|
|
delete(record) {
|
|
this._delete(record);
|
|
this._flushUpdateCycle();
|
|
}
|
|
|
|
/**
|
|
* Delete all records.
|
|
*/
|
|
deleteAll() {
|
|
for (const Model of Object.values(this.env.models)) {
|
|
for (const record of Object.values(Model.__records)) {
|
|
this._delete(record);
|
|
}
|
|
}
|
|
this._flushUpdateCycle();
|
|
}
|
|
|
|
/**
|
|
* Returns whether the given record still exists.
|
|
*
|
|
* @param {mail.model} Model class
|
|
* @param {mail.model} record
|
|
* @returns {boolean}
|
|
*/
|
|
exists(Model, record) {
|
|
return Model.__records[record.localId] ? true : false;
|
|
}
|
|
|
|
/**
|
|
* Get the record of provided model that has provided
|
|
* criteria, if it exists.
|
|
*
|
|
* @param {mail.model} Model class
|
|
* @param {function} findFunc
|
|
* @returns {mail.model|undefined} the record of model matching criteria, if
|
|
* exists.
|
|
*/
|
|
find(Model, findFunc) {
|
|
return this.all(Model).find(findFunc);
|
|
}
|
|
|
|
/**
|
|
* Gets the unique record of provided model that matches the given
|
|
* identifying data, if it exists.
|
|
* @see `_createRecordLocalId` for criteria of identification.
|
|
*
|
|
* @param {mail.model} Model class
|
|
* @param {Object} data
|
|
* @returns {mail.model|undefined}
|
|
*/
|
|
findFromIdentifyingData(Model, data) {
|
|
const localId = Model._createRecordLocalId(data);
|
|
return Model.get(localId);
|
|
}
|
|
|
|
/**
|
|
* This method returns the record of provided model that matches provided
|
|
* local id. Useful to convert a local id to a record.
|
|
* Note that even if there's a record in the system having provided local
|
|
* id, if the resulting record is not an instance of this model, this getter
|
|
* assumes the record does not exist.
|
|
*
|
|
* @param {mail.model} Model class
|
|
* @param {string} localId
|
|
* @param {Object} param2
|
|
* @param {boolean} [param2.isCheckingInheritance=false]
|
|
* @returns {mail.model|undefined} record, if exists
|
|
*/
|
|
get(Model, localId, { isCheckingInheritance = false } = {}) {
|
|
if (!localId) {
|
|
return;
|
|
}
|
|
const record = Model.__records[localId];
|
|
if (record) {
|
|
return record;
|
|
}
|
|
if (!isCheckingInheritance) {
|
|
return;
|
|
}
|
|
// support for inherited models (eg. relation targeting `mail.model`)
|
|
for (const SubModel of Object.values(this.env.models)) {
|
|
if (!(SubModel.prototype instanceof Model)) {
|
|
continue;
|
|
}
|
|
const record = SubModel.__records[localId];
|
|
if (record) {
|
|
return record;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* This method creates a record or updates one of provided Model, based on
|
|
* provided data. This method assumes that records are uniquely identifiable
|
|
* per "unique find" criteria from data on Model.
|
|
*
|
|
* @param {mail.model} Model class
|
|
* @param {Object|Object[]} data
|
|
* If data is an iterable, multiple records will be created/updated.
|
|
* @returns {mail.model|mail.model[]} created or updated record(s).
|
|
*/
|
|
insert(Model, data) {
|
|
const res = this._insert(Model, data);
|
|
this._flushUpdateCycle();
|
|
return res;
|
|
}
|
|
|
|
/**
|
|
* Process an update on provided record with provided data. Updating
|
|
* a record consists of applying direct updates first (i.e. explicit
|
|
* ones from `data`) and then indirect ones (i.e. compute/related fields
|
|
* and "after updates").
|
|
*
|
|
* @param {mail.model} record
|
|
* @param {Object} data
|
|
* @returns {boolean} whether any value changed for the current record
|
|
*/
|
|
update(record, data) {
|
|
const res = this._update(record, data);
|
|
this._flushUpdateCycle();
|
|
return res;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @private
|
|
* @param {mail.model} Model class
|
|
* @param {Object} patch
|
|
*/
|
|
_applyModelPatchFields(Model, patch) {
|
|
for (const [fieldName, field] of Object.entries(patch)) {
|
|
if (!Model.fields[fieldName]) {
|
|
Model.fields[fieldName] = field;
|
|
} else {
|
|
Object.assign(Model.fields[fieldName].dependencies, field.dependencies);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Object} Models
|
|
* @throws {Error} in case some declared fields are not correct.
|
|
*/
|
|
_checkDeclaredFieldsOnModels(Models) {
|
|
for (const Model of Object.values(Models)) {
|
|
for (const fieldName in Model.fields) {
|
|
const field = Model.fields[fieldName];
|
|
// 0. Get parented declared fields
|
|
const parentedMatchingFields = [];
|
|
let TargetModel = Model.__proto__;
|
|
while (Models[TargetModel.modelName]) {
|
|
if (TargetModel.fields) {
|
|
const matchingField = TargetModel.fields[fieldName];
|
|
if (matchingField) {
|
|
parentedMatchingFields.push(matchingField);
|
|
}
|
|
}
|
|
TargetModel = TargetModel.__proto__;
|
|
}
|
|
// 1. Field type is required.
|
|
if (!(['attribute', 'relation'].includes(field.fieldType))) {
|
|
throw new Error(`Field "${Model.modelName}/${fieldName}" has unsupported type ${field.fieldType}.`);
|
|
}
|
|
// 2. Invalid keys based on field type.
|
|
if (field.fieldType === 'attribute') {
|
|
const invalidKeys = Object.keys(field).filter(key =>
|
|
![
|
|
'compute',
|
|
'default',
|
|
'dependencies',
|
|
'fieldType',
|
|
'readonly',
|
|
'related',
|
|
'required',
|
|
].includes(key)
|
|
);
|
|
if (invalidKeys.length > 0) {
|
|
throw new Error(`Field "${Model.modelName}/${fieldName}" contains some invalid keys: "${invalidKeys.join(", ")}".`);
|
|
}
|
|
}
|
|
if (field.fieldType === 'relation') {
|
|
const invalidKeys = Object.keys(field).filter(key =>
|
|
![
|
|
'compute',
|
|
'default',
|
|
'dependencies',
|
|
'fieldType',
|
|
'inverse',
|
|
'isCausal',
|
|
'readonly',
|
|
'related',
|
|
'relationType',
|
|
'required',
|
|
'to',
|
|
].includes(key)
|
|
);
|
|
if (invalidKeys.length > 0) {
|
|
throw new Error(`Field "${Model.modelName}/${fieldName}" contains some invalid keys: "${invalidKeys.join(", ")}".`);
|
|
}
|
|
if (!Models[field.to]) {
|
|
throw new Error(`Relational field "${Model.modelName}/${fieldName}" targets to unknown model name "${field.to}".`);
|
|
}
|
|
if (field.isCausal && !(['one2many', 'one2one'].includes(field.relationType))) {
|
|
throw new Error(`Relational field "${Model.modelName}/${fieldName}" has "isCausal" true with a relation of type "${field.relationType}" but "isCausal" is only supported for "one2many" and "one2one".`);
|
|
}
|
|
if (field.required && !(['one2one', 'many2one'].includes(field.relationType))) {
|
|
throw new Error(`Relational field "${Model.modelName}/${fieldName}" has "required" true with a relation of type "${field.relationType}" but "required" is only supported for "one2one" and "many2one".`);
|
|
}
|
|
}
|
|
// 3. Computed field.
|
|
if (field.compute && !(typeof field.compute === 'string')) {
|
|
throw new Error(`Field "${Model.modelName}/${fieldName}" property "compute" must be a string (instance method name).`);
|
|
}
|
|
if (field.compute && !(Model.prototype[field.compute])) {
|
|
throw new Error(`Field "${Model.modelName}/${fieldName}" property "compute" does not refer to an instance method of this Model.`);
|
|
}
|
|
if (
|
|
field.dependencies &&
|
|
(!field.compute && !parentedMatchingFields.some(field => field.compute))
|
|
) {
|
|
throw new Error(`Field "${Model.modelName}/${fieldName} contains dependendencies but no compute method in itself or parented matching fields (dependencies only make sense for compute fields)."`);
|
|
}
|
|
if (
|
|
(field.compute || parentedMatchingFields.some(field => field.compute)) &&
|
|
(field.dependencies || parentedMatchingFields.some(field => field.dependencies))
|
|
) {
|
|
if (!(field.dependencies instanceof Array)) {
|
|
throw new Error(`Compute field "${Model.modelName}/${fieldName}" dependencies must be an array of field names.`);
|
|
}
|
|
const unknownDependencies = field.dependencies.every(dependency => !(Model.fields[dependency]));
|
|
if (unknownDependencies.length > 0) {
|
|
throw new Error(`Compute field "${Model.modelName}/${fieldName}" contains some unknown dependencies: "${unknownDependencies.join(", ")}".`);
|
|
}
|
|
}
|
|
// 4. Related field.
|
|
if (field.compute && field.related) {
|
|
throw new Error(`Field "${Model.modelName}/${fieldName}" cannot be a related and compute field at the same time.`);
|
|
}
|
|
if (field.related) {
|
|
if (!(typeof field.related === 'string')) {
|
|
throw new Error(`Field "${Model.modelName}/${fieldName}" property "related" has invalid format.`);
|
|
}
|
|
const [relationName, relatedFieldName, other] = field.related.split('.');
|
|
if (!relationName || !relatedFieldName || other) {
|
|
throw new Error(`Field "${Model.modelName}/${fieldName}" property "related" has invalid format.`);
|
|
}
|
|
// find relation on self or parents.
|
|
let relatedRelation;
|
|
let TargetModel = Model;
|
|
while (Models[TargetModel.modelName] && !relatedRelation) {
|
|
if (TargetModel.fields) {
|
|
relatedRelation = TargetModel.fields[relationName];
|
|
}
|
|
TargetModel = TargetModel.__proto__;
|
|
}
|
|
if (!relatedRelation) {
|
|
throw new Error(`Related field "${Model.modelName}/${fieldName}" relates to unknown relation name "${relationName}".`);
|
|
}
|
|
if (relatedRelation.fieldType !== 'relation') {
|
|
throw new Error(`Related field "${Model.modelName}/${fieldName}" relates to non-relational field "${relationName}".`);
|
|
}
|
|
// Assuming related relation is valid...
|
|
// find field name on related model or any parents.
|
|
const RelatedModel = Models[relatedRelation.to];
|
|
let relatedField;
|
|
TargetModel = RelatedModel;
|
|
while (Models[TargetModel.modelName] && !relatedField) {
|
|
if (TargetModel.fields) {
|
|
relatedField = TargetModel.fields[relatedFieldName];
|
|
}
|
|
TargetModel = TargetModel.__proto__;
|
|
}
|
|
if (!relatedField) {
|
|
throw new Error(`Related field "${Model.modelName}/${fieldName}" relates to unknown related model field "${relatedFieldName}".`);
|
|
}
|
|
if (relatedField.fieldType !== field.fieldType) {
|
|
throw new Error(`Related field "${Model.modelName}/${fieldName}" has mismatch type with its related model field.`);
|
|
}
|
|
if (
|
|
relatedField.fieldType === 'relation' &&
|
|
relatedField.to !== field.to
|
|
) {
|
|
throw new Error(`Related field "${Model.modelName}/${fieldName}" has mismatch target model name with its related model field.`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {Object} Models
|
|
* @throws {Error} in case some fields are not correct.
|
|
*/
|
|
_checkProcessedFieldsOnModels(Models) {
|
|
for (const Model of Object.values(Models)) {
|
|
for (const fieldName in Model.fields) {
|
|
const field = Model.fields[fieldName];
|
|
if (!(['attribute', 'relation'].includes(field.fieldType))) {
|
|
throw new Error(`Field "${Model.modelName}/${fieldName}" has unsupported type ${field.fieldType}.`);
|
|
}
|
|
if (field.compute && field.related) {
|
|
throw new Error(`Field "${Model.modelName}/${fieldName}" cannot be a related and compute field at the same time.`);
|
|
}
|
|
if (field.fieldType === 'attribute') {
|
|
continue;
|
|
}
|
|
if (!field.relationType) {
|
|
throw new Error(
|
|
`Field "${Model.modelName}/${fieldName}" must define a relation type in "relationType".`
|
|
);
|
|
}
|
|
if (!(['one2one', 'one2many', 'many2one', 'many2many'].includes(field.relationType))) {
|
|
throw new Error(
|
|
`Field "${Model.modelName}/${fieldName}" has invalid relation type "${field.relationType}".`
|
|
);
|
|
}
|
|
if (!field.inverse) {
|
|
throw new Error(
|
|
`Field "${
|
|
Model.modelName
|
|
}/${
|
|
fieldName
|
|
}" must define an inverse relation name in "inverse".`
|
|
);
|
|
}
|
|
if (!field.to) {
|
|
throw new Error(
|
|
`Relation "${
|
|
Model.modelNames
|
|
}/${
|
|
fieldName
|
|
}" must define a model name in "to" (1st positional parameter of relation field helpers).`
|
|
);
|
|
}
|
|
const RelatedModel = Models[field.to];
|
|
if (!RelatedModel) {
|
|
throw new Error(
|
|
`Model name of relation "${Model.modelName}/${fieldName}" does not exist.`
|
|
);
|
|
}
|
|
const inverseField = RelatedModel.fields[field.inverse];
|
|
if (!inverseField) {
|
|
throw new Error(
|
|
`Relation "${
|
|
Model.modelName
|
|
}/${
|
|
fieldName
|
|
}" has no inverse field "${RelatedModel.modelName}/${field.inverse}".`
|
|
);
|
|
}
|
|
if (inverseField.inverse !== fieldName) {
|
|
throw new Error(
|
|
`Inverse field name of relation "${
|
|
Model.modelName
|
|
}/${
|
|
fieldName
|
|
}" does not match with field name of relation "${
|
|
RelatedModel.modelName
|
|
}/${
|
|
inverseField.inverse
|
|
}".`
|
|
);
|
|
}
|
|
const allSelfAndParentNames = [];
|
|
let TargetModel = Model;
|
|
while (TargetModel) {
|
|
allSelfAndParentNames.push(TargetModel.modelName);
|
|
TargetModel = TargetModel.__proto__;
|
|
}
|
|
if (!allSelfAndParentNames.includes(inverseField.to)) {
|
|
throw new Error(
|
|
`Relation "${
|
|
Model.modelName
|
|
}/${
|
|
fieldName
|
|
}" has inverse relation "${
|
|
RelatedModel.modelName
|
|
}/${
|
|
field.inverse
|
|
}" misconfigured (currently "${
|
|
inverseField.to
|
|
}", should instead refer to this model or parented models: ${
|
|
allSelfAndParentNames.map(name => `"${name}"`).join(', ')
|
|
}?)`
|
|
);
|
|
}
|
|
if (
|
|
(field.relationType === 'many2many' && inverseField.relationType !== 'many2many') ||
|
|
(field.relationType === 'one2one' && inverseField.relationType !== 'one2one') ||
|
|
(field.relationType === 'one2many' && inverseField.relationType !== 'many2one') ||
|
|
(field.relationType === 'many2one' && inverseField.relationType !== 'one2many')
|
|
) {
|
|
throw new Error(
|
|
`Mismatch relations types "${
|
|
Model.modelName
|
|
}/${
|
|
fieldName
|
|
}" (${
|
|
field.relationType
|
|
}) and "${
|
|
RelatedModel.modelName
|
|
}/${
|
|
field.inverse
|
|
}" (${
|
|
inverseField.relationType
|
|
}).`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {mail.model} Model class
|
|
* @param {Object|Object[]} [data={}]
|
|
* @returns {mail.model|mail.model[]}
|
|
*/
|
|
_create(Model, data = {}) {
|
|
const isMulti = typeof data[Symbol.iterator] === 'function';
|
|
const dataList = isMulti ? data : [data];
|
|
const records = [];
|
|
for (const data of dataList) {
|
|
/**
|
|
* 1. Ensure the record can be created: localId must be unique.
|
|
*/
|
|
const localId = Model._createRecordLocalId(data);
|
|
if (Model.get(localId)) {
|
|
throw Error(`A record already exists for model "${Model.modelName}" with localId "${localId}".`);
|
|
}
|
|
/**
|
|
* 2. Prepare record state. Assign various keys and values that are
|
|
* expected to be found on every record.
|
|
*/
|
|
const record = new Model({ valid: true });
|
|
Object.assign(record, {
|
|
// The messaging env.
|
|
env: this.env,
|
|
// The unique record identifier.
|
|
localId,
|
|
// Field values of record.
|
|
__values: {},
|
|
// revNumber of record for detecting changes in useStore.
|
|
__state: 0,
|
|
});
|
|
// Ensure X2many relations are Set initially (other fields can stay undefined).
|
|
for (const field of Model.__fieldList) {
|
|
if (field.fieldType === 'relation') {
|
|
if (['one2many', 'many2many'].includes(field.relationType)) {
|
|
record.__values[field.fieldName] = new Set();
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* 3. Register record and invoke the life-cycle hook `_willCreate.`
|
|
* After this step the record is in a functioning state and it is
|
|
* considered existing.
|
|
*/
|
|
Model.__records[record.localId] = record;
|
|
record._willCreate();
|
|
/**
|
|
* 4. Write provided data, default data, and register computes.
|
|
*/
|
|
const data2 = {};
|
|
for (const field of Model.__fieldList) {
|
|
// `undefined` should have the same effect as not passing the field
|
|
if (data[field.fieldName] !== undefined) {
|
|
data2[field.fieldName] = data[field.fieldName];
|
|
} else {
|
|
data2[field.fieldName] = field.default;
|
|
}
|
|
if (field.compute || field.related) {
|
|
// new record should always invoke computed fields.
|
|
this._registerToComputeField(record, field);
|
|
}
|
|
}
|
|
this._update(record, data2, { allowWriteReadonly: true });
|
|
/**
|
|
* 5. Register post processing operation that are to be delayed at
|
|
* the end of the update cycle.
|
|
*/
|
|
this._createdRecords.add(record);
|
|
this._hasAnyChangeDuringCycle = true;
|
|
|
|
records.push(record);
|
|
}
|
|
return isMulti ? records : records[0];
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {mail.model} record
|
|
*/
|
|
_delete(record) {
|
|
const Model = record.constructor;
|
|
if (!record.exists()) {
|
|
throw Error(`Cannot delete already deleted record ${record.localId}.`);
|
|
}
|
|
record._willDelete();
|
|
for (const field of Model.__fieldList) {
|
|
if (field.fieldType === 'relation') {
|
|
// ensure inverses are properly unlinked
|
|
field.parseAndExecuteCommands(record, [['unlink-all']]);
|
|
}
|
|
}
|
|
this._hasAnyChangeDuringCycle = true;
|
|
// TODO ideally deleting the record should be done at the top of the
|
|
// method, and it shouldn't be needed to manually remove
|
|
// _toComputeFields and _toUpdateAfters, but it is not possible until
|
|
// related are also properly unlinked during `set`
|
|
this._toComputeFields.delete(record);
|
|
this._toUpdateAfters.delete(record);
|
|
delete Model.__records[record.localId];
|
|
}
|
|
|
|
/**
|
|
* Terminates an update cycle by executing its pending operations: execute
|
|
* computed fields, execute life-cycle hooks, update rev numbers.
|
|
*
|
|
* @private
|
|
*/
|
|
_flushUpdateCycle(func) {
|
|
// Execution of computes
|
|
while (this._toComputeFields.size > 0) {
|
|
for (const [record, fields] of this._toComputeFields) {
|
|
// delete at every step to avoid recursion, indeed doCompute
|
|
// might trigger an update cycle itself
|
|
this._toComputeFields.delete(record);
|
|
if (!record.exists()) {
|
|
throw Error(`Cannot execute computes for already deleted record ${record.localId}.`);
|
|
}
|
|
while (fields.size > 0) {
|
|
for (const field of fields) {
|
|
// delete at every step to avoid recursion
|
|
fields.delete(field);
|
|
if (field.compute) {
|
|
this._update(record, { [field.fieldName]: record[field.compute]() }, { allowWriteReadonly: true });
|
|
continue;
|
|
}
|
|
if (field.related) {
|
|
this._update(record, { [field.fieldName]: field.computeRelated(record) }, { allowWriteReadonly: true });
|
|
continue;
|
|
}
|
|
throw new Error("No compute method defined on this field definition");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Execution of _updateAfter
|
|
while (this._toUpdateAfters.size > 0) {
|
|
for (const [record, previous] of this._toUpdateAfters) {
|
|
// delete at every step to avoid recursion, indeed _updateAfter
|
|
// might trigger an update cycle itself
|
|
this._toUpdateAfters.delete(record);
|
|
if (!record.exists()) {
|
|
throw Error(`Cannot _updateAfter for already deleted record ${record.localId}.`);
|
|
}
|
|
record._updateAfter(previous);
|
|
}
|
|
}
|
|
|
|
// Execution of _created
|
|
while (this._createdRecords.size > 0) {
|
|
for (const record of this._createdRecords) {
|
|
// delete at every step to avoid recursion, indeed _created
|
|
// might trigger an update cycle itself
|
|
this._createdRecords.delete(record);
|
|
if (!record.exists()) {
|
|
throw Error(`Cannot call _created for already deleted record ${record.localId}.`);
|
|
}
|
|
record._created();
|
|
}
|
|
}
|
|
|
|
// Increment record rev number (for useStore comparison)
|
|
for (const record of this._updatedRecords) {
|
|
record.__state++;
|
|
}
|
|
|
|
// handle required field.
|
|
for (const record of this._updatedRecords) {
|
|
for (const required of record.constructor.__requiredFieldsList) {
|
|
if (record[required.fieldName] === undefined) {
|
|
throw Error(`Field ${required.fieldName} of ${record.localId} is required.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
this._updatedRecords.clear();
|
|
|
|
// Trigger at most one useStore call per update cycle
|
|
if (this._hasAnyChangeDuringCycle) {
|
|
this.env.store.state.messagingRevNumber++;
|
|
this._hasAnyChangeDuringCycle = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @returns {Object}
|
|
* @throws {Error} in case it cannot generate models.
|
|
*/
|
|
_generateModels() {
|
|
const allNames = Object.keys(registry);
|
|
const Models = {};
|
|
const generatedNames = [];
|
|
let toGenerateNames = [...allNames];
|
|
while (toGenerateNames.length > 0) {
|
|
const generatable = toGenerateNames.map(name => registry[name]).find(entry => {
|
|
let isGenerateable = true;
|
|
for (const dependencyName of entry.dependencies) {
|
|
if (!generatedNames.includes(dependencyName)) {
|
|
isGenerateable = false;
|
|
}
|
|
}
|
|
return isGenerateable;
|
|
});
|
|
if (!generatable) {
|
|
throw new Error(`Cannot generate following Model: ${toGenerateNames.join(', ')}`);
|
|
}
|
|
// Make environment accessible from Model.
|
|
const Model = generatable.factory(Models);
|
|
Model.env = this.env;
|
|
/**
|
|
* Contains all records. key is local id, while value is the record.
|
|
*/
|
|
Model.__records = {};
|
|
for (const patch of generatable.patches) {
|
|
switch (patch.type) {
|
|
case 'class':
|
|
patchClassMethods(Model, patch.name, patch.patch);
|
|
break;
|
|
case 'instance':
|
|
patchInstanceMethods(Model, patch.name, patch.patch);
|
|
break;
|
|
case 'field':
|
|
this._applyModelPatchFields(Model, patch.patch);
|
|
break;
|
|
}
|
|
}
|
|
if (!Object.prototype.hasOwnProperty.call(Model, 'modelName')) {
|
|
throw new Error(`Missing static property "modelName" on Model class "${Model.name}".`);
|
|
}
|
|
if (generatedNames.includes(Model.modelName)) {
|
|
throw new Error(`Duplicate model name "${Model.modelName}" shared on 2 distinct Model classes.`);
|
|
}
|
|
Models[Model.modelName] = Model;
|
|
generatedNames.push(Model.modelName);
|
|
toGenerateNames = toGenerateNames.filter(name => name !== Model.modelName);
|
|
}
|
|
/**
|
|
* Check that declared model fields are correct.
|
|
*/
|
|
this._checkDeclaredFieldsOnModels(Models);
|
|
/**
|
|
* Process declared model fields definitions, so that these field
|
|
* definitions are much easier to use in the system. For instance, all
|
|
* relational field definitions have an inverse, or fields track all their
|
|
* dependents.
|
|
*/
|
|
this._processDeclaredFieldsOnModels(Models);
|
|
/**
|
|
* Check that all model fields are correct, notably one relation
|
|
* should have matching reversed relation.
|
|
*/
|
|
this._checkProcessedFieldsOnModels(Models);
|
|
return Models;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {mail.model}
|
|
* @param {Object|Object[]} data
|
|
* @returns {mail.model|mail.model[]}
|
|
*/
|
|
_insert(Model, data) {
|
|
const isMulti = typeof data[Symbol.iterator] === 'function';
|
|
const dataList = isMulti ? data : [data];
|
|
const records = [];
|
|
for (const data of dataList) {
|
|
let record = Model.findFromIdentifyingData(data);
|
|
if (!record) {
|
|
record = this._create(Model, data);
|
|
} else {
|
|
this._update(record, data);
|
|
}
|
|
records.push(record);
|
|
}
|
|
return isMulti ? records : records[0];
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {mail.model} Model class
|
|
* @param {ModelField} field
|
|
* @returns {ModelField}
|
|
*/
|
|
_makeInverseRelationField(Model, field) {
|
|
const relFunc =
|
|
field.relationType === 'many2many' ? ModelField.many2many
|
|
: field.relationType === 'many2one' ? ModelField.one2many
|
|
: field.relationType === 'one2many' ? ModelField.many2one
|
|
: field.relationType === 'one2one' ? ModelField.one2one
|
|
: undefined;
|
|
if (!relFunc) {
|
|
throw new Error(`Cannot compute inverse Relation of "${Model.modelName}/${field.fieldName}".`);
|
|
}
|
|
const inverseField = new ModelField(Object.assign(
|
|
{},
|
|
relFunc(Model.modelName, { inverse: field.fieldName }),
|
|
{
|
|
env: this.env,
|
|
fieldName: `_inverse_${Model.modelName}/${field.fieldName}`,
|
|
modelManager: this,
|
|
}
|
|
));
|
|
return inverseField;
|
|
}
|
|
|
|
/**
|
|
* This function processes definition of declared fields in provided models.
|
|
* Basically, models have fields declared in static prop `fields`, and this
|
|
* function processes and modifies them in place so that they are fully
|
|
* configured. For instance, model relations need bi-directional mapping, but
|
|
* inverse relation may be omitted in declared field: this function auto-fill
|
|
* this inverse relation.
|
|
*
|
|
* @private
|
|
* @param {Object} Models
|
|
*/
|
|
_processDeclaredFieldsOnModels(Models) {
|
|
/**
|
|
* 1. Prepare fields.
|
|
*/
|
|
for (const Model of Object.values(Models)) {
|
|
if (!Object.prototype.hasOwnProperty.call(Model, 'fields')) {
|
|
Model.fields = {};
|
|
}
|
|
Model.inverseRelations = [];
|
|
// Make fields aware of their field name.
|
|
for (const [fieldName, fieldData] of Object.entries(Model.fields)) {
|
|
Model.fields[fieldName] = new ModelField(Object.assign({}, fieldData, {
|
|
env: this.env,
|
|
fieldName,
|
|
modelManager: this,
|
|
}));
|
|
}
|
|
}
|
|
/**
|
|
* 2. Auto-generate definitions of undeclared inverse relations.
|
|
*/
|
|
for (const Model of Object.values(Models)) {
|
|
for (const field of Object.values(Model.fields)) {
|
|
if (field.fieldType !== 'relation') {
|
|
continue;
|
|
}
|
|
if (field.inverse) {
|
|
continue;
|
|
}
|
|
const RelatedModel = Models[field.to];
|
|
const inverseField = this._makeInverseRelationField(Model, field);
|
|
field.inverse = inverseField.fieldName;
|
|
RelatedModel.fields[inverseField.fieldName] = inverseField;
|
|
}
|
|
}
|
|
/**
|
|
* 3. Generate dependents and inverse-relates on fields.
|
|
* Field definitions are not yet combined, so registration of `dependents`
|
|
* may have to walk structural hierarchy of models in order to find
|
|
* the appropriate field. Also, while dependencies are defined just with
|
|
* field names, dependents require an additional data called a "hash"
|
|
* (= field id), which is a way to identify dependents in an inverse
|
|
* relation. This is necessary because dependents are a subset of an inverse
|
|
* relation.
|
|
*/
|
|
for (const Model of Object.values(Models)) {
|
|
for (const field of Object.values(Model.fields)) {
|
|
for (const dependencyFieldName of field.dependencies) {
|
|
let TargetModel = Model;
|
|
let dependencyField = TargetModel.fields[dependencyFieldName];
|
|
while (!dependencyField) {
|
|
TargetModel = TargetModel.__proto__;
|
|
dependencyField = TargetModel.fields[dependencyFieldName];
|
|
}
|
|
const dependent = [field.id, field.fieldName].join(DEPENDENT_INNER_SEPARATOR);
|
|
dependencyField.dependents = [
|
|
...new Set(dependencyField.dependents.concat([dependent]))
|
|
];
|
|
}
|
|
if (field.related) {
|
|
const [relationName, relatedFieldName] = field.related.split('.');
|
|
let TargetModel = Model;
|
|
let relationField = TargetModel.fields[relationName];
|
|
while (!relationField) {
|
|
TargetModel = TargetModel.__proto__;
|
|
relationField = TargetModel.fields[relationName];
|
|
}
|
|
const relationFieldDependent = [
|
|
field.id,
|
|
field.fieldName,
|
|
].join(DEPENDENT_INNER_SEPARATOR);
|
|
relationField.dependents = [
|
|
...new Set(relationField.dependents.concat([relationFieldDependent]))
|
|
];
|
|
const OtherModel = Models[relationField.to];
|
|
let OtherTargetModel = OtherModel;
|
|
let relatedField = OtherTargetModel.fields[relatedFieldName];
|
|
while (!relatedField) {
|
|
OtherTargetModel = OtherTargetModel.__proto__;
|
|
relatedField = OtherTargetModel.fields[relatedFieldName];
|
|
}
|
|
const relatedFieldDependent = [
|
|
field.id,
|
|
relationField.inverse,
|
|
field.fieldName,
|
|
].join(DEPENDENT_INNER_SEPARATOR);
|
|
relatedField.dependents = [
|
|
...new Set(
|
|
relatedField.dependents.concat([relatedFieldDependent])
|
|
)
|
|
];
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* 4. Extend definition of fields of a model with the definition of
|
|
* fields of its parents. Field definitions on self has precedence over
|
|
* parented fields.
|
|
*/
|
|
for (const Model of Object.values(Models)) {
|
|
Model.__combinedFields = {};
|
|
for (const field of Object.values(Model.fields)) {
|
|
Model.__combinedFields[field.fieldName] = field;
|
|
}
|
|
let TargetModel = Model.__proto__;
|
|
while (TargetModel && TargetModel.fields) {
|
|
for (const targetField of Object.values(TargetModel.fields)) {
|
|
const field = Model.__combinedFields[targetField.fieldName];
|
|
if (field) {
|
|
Model.__combinedFields[targetField.fieldName] = field.combine(targetField);
|
|
} else {
|
|
Model.__combinedFields[targetField.fieldName] = targetField;
|
|
}
|
|
}
|
|
TargetModel = TargetModel.__proto__;
|
|
}
|
|
}
|
|
/**
|
|
* 5. Register final fields and make field accessors, to redirects field
|
|
* access to field getter and to prevent field from being written
|
|
* without calling update (which is necessary to process update cycle).
|
|
*/
|
|
for (const Model of Object.values(Models)) {
|
|
// Object with fieldName/field as key/value pair, for quick access.
|
|
Model.__fieldMap = Model.__combinedFields;
|
|
// List of all fields, for iterating.
|
|
Model.__fieldList = Object.values(Model.__fieldMap);
|
|
Model.__requiredFieldsList = Model.__fieldList.filter(
|
|
field => field.required
|
|
);
|
|
// Add field accessors.
|
|
for (const field of Model.__fieldList) {
|
|
Object.defineProperty(Model.prototype, field.fieldName, {
|
|
get() {
|
|
return field.get(this); // this is bound to record
|
|
},
|
|
});
|
|
}
|
|
delete Model.__combinedFields;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Registers compute of dependents for the given field, if applicable.
|
|
*
|
|
* @private
|
|
* @param {mail.model} record
|
|
* @param {ModelField} field
|
|
*/
|
|
_registerComputeOfDependents(record, field) {
|
|
const Model = record.constructor;
|
|
for (const dependent of field.dependents) {
|
|
const [hash, fieldName1, fieldName2] = dependent.split(
|
|
this.DEPENDENT_INNER_SEPARATOR
|
|
);
|
|
const field1 = Model.__fieldMap[fieldName1];
|
|
if (fieldName2) {
|
|
// "fieldName1.fieldName2" -> dependent is on another record
|
|
if (['one2many', 'many2many'].includes(field1.relationType)) {
|
|
for (const otherRecord of record[fieldName1]) {
|
|
const OtherModel = otherRecord.constructor;
|
|
const field2 = OtherModel.__fieldMap[fieldName2];
|
|
if (field2 && field2.hashes.includes(hash)) {
|
|
this._registerToComputeField(otherRecord, field2);
|
|
}
|
|
}
|
|
} else {
|
|
const otherRecord = record[fieldName1];
|
|
if (!otherRecord) {
|
|
continue;
|
|
}
|
|
const OtherModel = otherRecord.constructor;
|
|
const field2 = OtherModel.__fieldMap[fieldName2];
|
|
if (field2 && field2.hashes.includes(hash)) {
|
|
this._registerToComputeField(otherRecord, field2);
|
|
}
|
|
}
|
|
} else {
|
|
// "fieldName1" only -> dependent is on current record
|
|
if (field1 && field1.hashes.includes(hash)) {
|
|
this._registerToComputeField(record, field1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register a pair record/field for the compute step of the update cycle in
|
|
* progress.
|
|
*
|
|
* @private
|
|
* @param {mail.model} record
|
|
* @param {ModelField} field
|
|
*/
|
|
_registerToComputeField(record, field) {
|
|
if (!this._toComputeFields.has(record)) {
|
|
this._toComputeFields.set(record, new Set());
|
|
}
|
|
this._toComputeFields.get(record).add(field);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {mail.model} record
|
|
* @param {Object} data
|
|
* @param {Object} [options]
|
|
* @param [options.allowWriteReadonly=false]
|
|
* @returns {boolean} whether any value changed for the current record
|
|
*/
|
|
_update(record, data, options = {}) {
|
|
if (!record.exists()) {
|
|
throw Error(`Cannot update already deleted record ${record.localId}.`);
|
|
}
|
|
const { allowWriteReadonly = false } = options;
|
|
if (!this._toUpdateAfters.has(record)) {
|
|
// queue updateAfter before calling field.set to ensure previous
|
|
// contains the value at the start of update cycle
|
|
this._toUpdateAfters.set(record, record._updateBefore());
|
|
}
|
|
const Model = record.constructor;
|
|
let hasChanged = false;
|
|
for (const fieldName of Object.keys(data)) {
|
|
if (data[fieldName] === undefined) {
|
|
// `undefined` should have the same effect as not passing the field
|
|
continue;
|
|
}
|
|
const field = Model.__fieldMap[fieldName];
|
|
if (!field) {
|
|
throw new Error(`Cannot create/update record with data unrelated to a field. (record: "${record.localId}", non-field attempted update: "${fieldName}")`);
|
|
}
|
|
if (field.readonly && !allowWriteReadonly) {
|
|
throw new Error(`Can't update "${field.fieldName}" (record: "${record.localId}") because it's readonly.`);
|
|
}
|
|
const newVal = data[fieldName];
|
|
if (!field.parseAndExecuteCommands(record, newVal, options)) {
|
|
continue;
|
|
}
|
|
hasChanged = true;
|
|
// flag all dependent fields for compute
|
|
this._registerComputeOfDependents(record, field);
|
|
}
|
|
if (hasChanged) {
|
|
this._updatedRecords.add(record);
|
|
this._hasAnyChangeDuringCycle = true;
|
|
}
|
|
return hasChanged;
|
|
}
|
|
|
|
}
|
|
|
|
return ModelManager;
|
|
|
|
});
|